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;
}

Attachment: setpgid-evidence.json
Description: application/json

  • Bash 5.3.... Leonid Komarovsky via Bug reports for the GNU Bourne Again SHell
    • Re: ... Leonid Komarovsky via Bug reports for the GNU Bourne Again SHell

Reply via email to