Hello, I'm seeing an intermittent diagnostic in interactive Bash 5.3.20 on macOS 27.0.1:
-bash: child setpgid (64828 to 64828): Operation not permitted It first appeared when opening an iTerm2 session. A clean interactive Bash launched with --noprofile --norc also reproduced it while executing a simple external command, so the startup files are not necessary. Two standalone C reproducers reproduce the underlying behavior without Bash. The parent calls setpgid(child_pid, child_pid), while the child calls setpgid(0, 0) or the equivalent explicit-PID request. If the calls overlap, one can fail with EPERM while the other succeeds and the final PGID is correct. Serialized calls did not fail in these tests. The independently written direct-system-call reproducer uses syscall(SYS_setpgid, ...) and gave these results: concurrent, 2000 iterations: 5 parent EPERM errors, 26 child EPERM errors parent-first, 1000 iterations: 0 errors child-first, 1000 iterations: 0 errors child-only, 1000 iterations: 0 errors There were no other errno values, no iterations where both requests failed, no incorrect final process groups, and no observed session changes. The child stays alive until the parent has read the result and checked its PGID. Neither reproducer calls exec or setsid; the child is not a session leader. errno is saved immediately after the call. An independent wrapper-based reproducer run directly in iTerm2 gave 6 parent and 12 child EPERM errors in 1000 concurrent iterations, versus zero errors in 1000 parent-then-child iterations. Tracing a real Bash occurrence also showed the parent succeeding and the child receiving EPERM with the correct resulting PGID. Environment: GNU Bash 5.3.20, Homebrew build macOS 27.0.1 (26A434), Darwin 27.0.0, XNU 13432.1.9~1 arm64, hardware model Mac17,6 Apple clang 21.0.0 (clang-2100.3.34.2) Target: arm64-apple-darwin27.0.0 Tests: October 18-19, 2026 Build and run the attached direct-system-call reproducer: xcrun clang -O2 -Wno-deprecated-declarations setpgid-direct-syscall-audit.c -o setpgid-audit ./setpgid-audit concurrent 2000 ./setpgid-audit parent-first 1000 ./setpgid-audit child-first 1000 ./setpgid-audit child-only 1000 An independent macOS report and application-specific mitigation are here: https://github.com/scholay/xsime/issues/81 https://github.com/scholay/xsime/commit/07c59c86c63854d1b1f69d018259d9270af11aae The pure-C reproduction points to behavior below Bash, which I am also taking to Apple. I'm notifying this list because of the user-visible diagnostic and to ask whether Bash should verify that the requested PGID was established before reporting this particular failure. I am not proposing that Bash ignore EPERM generally, and I have not established the exact kernel defect or the macOS version that introduced it. Both C sources and the measured results are attached. No workaround or shell-configuration change has been installed. Thanks, Leonid
/*
* Reproducer for overlapping setpgid requests on this Mac.
* Compile: xcrun clang -O2 setpgid-reproducer.c -o setpgid-reproducer
* Compare: ./setpgid-reproducer concurrent 1000
* ./setpgid-reproducer parent-then-child 1000
*
* Each child remains alive until both observations have been collected.
* No child execs another program, and parent and child remain in one session.
* The only difference between these modes is whether their setpgid calls can
overlap.
*
* Native iTerm test, 2026-10-18, macOS 27.0.1 (26A434), Darwin 27.0.0:
* Concurrent: 12 child EPERM failures + 6 parent EPERM failures / 1000
iterations.
* Parent then child: 0 failures / 1000 iterations.
* All 2000 iterations ended with the requested process-group assignment.
* This source is a diagnostic, not a workaround or a configuration change.
*/
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
struct observation {
pid_t pid, sid, before, after;
int result, error;
};
static void fail(const char *operation) {
perror(operation);
exit(2);
}
int main(int argc, char **argv) {
const char *mode = argc > 1 ? argv[1] : "concurrent";
int iterations = argc > 2 ? atoi(argv[2]) : 1000;
int child_failures = 0, parent_failures = 0, wrong_groups = 0;
for (int attempt = 0; attempt < iterations; ++attempt) {
int report[2], start[2], finish[2];
if (pipe(report) || pipe(start) || pipe(finish)) fail("pipe");
pid_t child = fork();
if (child < 0) fail("fork");
if (child == 0) {
close(report[0]); close(start[1]); close(finish[1]);
if (strcmp(mode, "parent-then-child") == 0) {
char byte;
if (read(start[0], &byte, 1) != 1) _exit(3);
}
struct observation observed = {0};
observed.pid = getpid();
observed.sid = getsid(0);
observed.before = getpgrp();
observed.result = setpgid(observed.pid, observed.pid);
observed.error = observed.result < 0 ? errno : 0;
observed.after = getpgrp();
if (write(report[1], &observed, sizeof(observed)) !=
sizeof(observed)) _exit(4);
char byte;
if (read(finish[0], &byte, 1) != 1) _exit(5);
_exit(0);
}
close(report[1]); close(start[0]); close(finish[0]);
int parent_result = 0, parent_error = 0;
if (strcmp(mode, "child-only") && strcmp(mode, "child-then-parent")) {
parent_result = setpgid(child, child);
parent_error = parent_result < 0 ? errno : 0;
}
if (strcmp(mode, "parent-then-child") == 0 && write(start[1], "x", 1)
!= 1) fail("start");
struct observation observed;
if (read(report[0], &observed, sizeof(observed)) != sizeof(observed))
fail("report");
if (strcmp(mode, "child-then-parent") == 0) {
parent_result = setpgid(child, child);
parent_error = parent_result < 0 ? errno : 0;
}
pid_t group = getpgid(child);
child_failures += observed.result < 0;
parent_failures += parent_result < 0;
wrong_groups += group != child;
if ((observed.result < 0 || parent_result < 0 || group != child) &&
child_failures + parent_failures + wrong_groups <= 6) {
printf("{\"event\":\"failure\",\"mode\":\"%s\",\"attempt\":%d,"
"\"pid\":%d,\"sid\":%d,\"child_pgid_before\":%d,"
"\"child_pgid_after\":%d,\"child_result\":%d,\"child_error\":%d,"
"\"parent_result\":%d,\"parent_error\":%d,\"final_pgid\":%d}\n",
mode, attempt, observed.pid, observed.sid, observed.before,
observed.after, observed.result, observed.error,
parent_result, parent_error, group);
}
if (write(finish[1], "x", 1) != 1) fail("finish");
int status;
if (waitpid(child, &status, 0) != child) fail("waitpid");
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "Child failed with status %d\n", status); return 3;
}
close(report[0]); close(start[1]); close(finish[1]);
}
printf("{\"event\":\"summary\",\"mode\":\"%s\",\"iterations\":%d,"
"\"child_failures\":%d,\"parent_failures\":%d,\"wrong_final_groups\":%d}\n",
mode, iterations, child_failures, parent_failures, wrong_groups);
return 0;
}
/* Independent audit of concurrent process-group assignment, 2026-10-19.
* Build: xcrun clang -O2 -Wno-deprecated-declarations
setpgid-direct-syscall-audit.c -o setpgid-audit
* Run: ./setpgid-audit concurrent 2000
* ./setpgid-audit parent-first 1000
* ./setpgid-audit child-first 1000
* ./setpgid-audit child-only 1000
* This diagnostic uses Darwin SYS_setpgid directly and does not change
settings.
*/
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>
struct result { int rc, error; pid_t sid, pgid; };
static void transfer(int fd, void *buffer, size_t size, int writing) {
char *cursor = buffer;
while (size) {
ssize_t n = writing ? write(fd, cursor, size) : read(fd, cursor, size);
if (n < 0 && errno == EINTR) continue;
if (n <= 0) { perror("socket transfer"); _exit(2); }
cursor += n; size -= (size_t)n;
}
}
int main(int argc, char **argv) {
if (argc != 3) return 2;
const char *mode = argv[1];
int count = atoi(argv[2]);
int child_errors = 0, parent_errors = 0, other_errors = 0;
int wrong_groups = 0, both_failed = 0, session_changes = 0;
pid_t session = getsid(0);
for (int i = 0; i < count; ++i) {
int pair[2];
if (socketpair(AF_UNIX, SOCK_STREAM, 0, pair)) return 2;
pid_t pid = fork();
if (pid < 0) return 2;
char ack = 'x';
if (pid == 0) {
close(pair[0]);
if (!strcmp(mode, "parent-first")) transfer(pair[1], &ack, 1, 0);
struct result child = {0};
child.rc = (int)syscall(SYS_setpgid, 0, 0);
child.error = child.rc < 0 ? errno : 0;
child.sid = getsid(0); child.pgid = getpgrp();
transfer(pair[1], &child, sizeof(child), 1);
transfer(pair[1], &ack, 1, 0);
_exit(0);
}
close(pair[1]);
int rc = 0, error = 0;
if (!strcmp(mode, "concurrent") || !strcmp(mode, "parent-first")) {
rc = (int)syscall(SYS_setpgid, pid, pid);
error = rc < 0 ? errno : 0;
}
if (!strcmp(mode, "parent-first")) transfer(pair[0], &ack, 1, 1);
struct result child;
transfer(pair[0], &child, sizeof(child), 0);
if (!strcmp(mode, "child-first")) {
rc = (int)syscall(SYS_setpgid, pid, pid);
error = rc < 0 ? errno : 0;
}
parent_errors += rc < 0; child_errors += child.rc < 0;
both_failed += rc < 0 && child.rc < 0;
other_errors += (rc < 0 && error != EPERM) || (child.rc < 0 &&
child.error != EPERM);
wrong_groups += getpgid(pid) != pid;
session_changes += child.sid != session;
transfer(pair[0], &ack, 1, 1);
close(pair[0]);
int status;
while (waitpid(pid, &status, 0) < 0) if (errno != EINTR) return 2;
if (!WIFEXITED(status) || WEXITSTATUS(status)) return 2;
}
printf("{\"api\":\"direct syscall\",\"mode\":\"%s\",\"iterations\":%d,"
"\"parent_errors\":%d,\"child_errors\":%d,\"non_eperm_errors\":%d,"
"\"both_failed\":%d,\"wrong_groups\":%d,\"session_changes\":%d}\n",
mode, count, parent_errors, child_errors, other_errors,
both_failed, wrong_groups, session_changes);
return 0;
}
setpgid-evidence.json
Description: application/json
