Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

CodexBarClaudeWatchdog post-spawn setpgid EACCES race + kill(-pgid) can signal parent process group

Open
#4,829 3 comments 1 reaction 0 assignees View on GitHub

Maintainers usually reply within 1 day

@Quinnod345 is already working on this.

Since Oct 28, 2026.

  • #4830 by @Quinnod345 — open

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
35/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Stale
Tech stack
macos, swift
Domain
backend, security

Research direction

Read Sources/CodexBarClaudeWatchdog/main.swift alongside Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift and ProcessOwnershipReaper.swift, which the issue cites as examples. Check both reported defects: child process group setup and kill targeting, then wait status handling when the parent exits. Done means the watchdog safely terminates and reaps its child while reporting the child's actual exit code; linked pull request #4830 is already open.

Written by the indexing model from the issue text.

Description

clawsweeper:fix-shape-clear clawsweeper:queueable-fix clawsweeper:source-repro impact:crash-loop issue-rating: 🦞 diamond lobster no-stale P2
Summary

While auditing Sources/CodexBarClaudeWatchdog/main.swift (and comparing it with Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift and ProcessOwnershipReaper.swift), we found and reproduced on macOS (Darwin arm64) two defects in CodexBarClaudeWatchdog:

  1. Post-spawn setpgid race (EACCES) + unguarded kill(-pgid) (Sources/CodexBarClaudeWatchdog/main.swift:17-38, 71, 85): posix_spawnp runs with attrp = nil (inheriting the watchdog's process group getpgrp()), and _ = setpgid(globalChildPID, globalChildPID) at line 85 fails with errno = 13 (EACCES) once the child has exec'd—leaving getpgid(childPID) == getpgrp(). When killProcessTree runs, if pgid > 0 { kill(-pgid, SIGTERM/SIGKILL) } checks only pgid > 0 without excluding getpgrp(), broadcasting SIGTERM and SIGKILL to the watchdog's own process group (killing the watchdog itself on SIGKILL escalation before waitpid can reap the child, as well as any caller sharing the watchdog's process group).
  2. killProcessTree reaps childPID and discards its wait status, causing getppid() == 1 teardown to exit 0 after ECHILD (Sources/CodexBarClaudeWatchdog/main.swift:28-29, 101, 108-118).

1. Post-spawn setpgid EACCES race and unguarded kill(-pgid) (Sources/CodexBarClaudeWatchdog/main.swift:17-38, 71, 85)

In Sources/CodexBarCore/Host/Process/SpawnedProcessGroup.swift:362-367, child processes are placed into their own process group atomically inside the kernel before exec via POSIX_SPAWN_SETPGROUP + posix_spawnattr_setpgroup(&attributes, 0), and ProcessOwnershipReaper.swift:38 explicitly guards negative-pgid kills with guard group > 0, group != getpgrp() else { return }.

By contrast, Sources/CodexBarClaudeWatchdog/main.swift:

  1. Spawns the child at line 71 with attrp = nil:
    posix_spawnp(&pid, childPath, nil, nil, cBuffer.baseAddress, environ)
    
    so the child inherits the watchdog's process group (getpgrp()).
  2. Calls _ = setpgid(globalChildPID, globalChildPID) from the parent at line 85 after posix_spawnp returns, ignoring errors. Once posix_spawnp has exec'd the child image in the kernel, setpgid(2) from the parent fails with EACCES (errno = 13), leaving globalChildPID in the watchdog's process group (getpgid(childPID) == getpgrp()).
  3. In killProcessTree (main.swift:18-23, 33-37), checks only if pgid > 0:
    let pgid = getpgid(childPID)
    if pgid > 0 {
        kill(-pgid, SIGTERM)
    }
    ...
    if pgid > 0 {
        kill(-pgid, SIGKILL)
    }
    
    When setpgid fails with EACCES, pgid == getpgrp() > 0:
    • Line 20 sends SIGTERM to -getpgrp() (including CodexBarClaudeWatchdog itself and any process sharing the watchdog's group when invoked outside SpawnedProcessGroup.launchPTY).
    • If the child does not exit within 0.5s, line 34 sends SIGKILL to -getpgrp(), which immediately terminates CodexBarClaudeWatchdog itself before lines 111/117 can reap globalChildPID or report the exit code.
Live macOS (Darwin) Reproduction
swift -e '
import Darwin
import Foundation

var pid: pid_t = 0
var argv: [UnsafeMutablePointer<CChar>?] = [strdup("/bin/sleep"), strdup("2"), nil]
_ = posix_spawnp(&pid, "/bin/sleep", nil, nil, &argv, environ)
usleep(20_000) // child image has execd
let rc = setpgid(pid, pid)
let err = errno
let childPgid = getpgid(pid)
let myPgrp = getpgrp()
kill(pid, SIGKILL)
var st: Int32 = 0
_ = waitpid(pid, &st, 0)
print("setpgid rc=\(rc) errno=\(err) (EACCES=\(EACCES)), childPgid=\(childPgid), watchdogPgrp=\(myPgrp), sameGroup=\(childPgid == myPgrp)")
'

Output on macOS:

setpgid rc=-1 errno=13 (EACCES=13), childPgid=84710, watchdogPgrp=84710, sameGroup=true

Suggested Fix:

  • Pass a posix_spawnattr_t configured solely with POSIX_SPAWN_SETPGROUP (posix_spawnattr_setpgroup(&attr, 0)) to posix_spawnp at main.swift:71 so the child enters its own process group (pgid == pid) atomically before exec while preserving existing descriptor inheritance.
  • Guard negative-pgid kills in killProcessTree (main.swift:19, 33) with if pgid > 0, pgid != getpgrp() { kill(-pgid, ...) } else { kill(childPID, ...) } (matching ProcessOwnershipReaper.swift:38).

2. Double waitpid in CodexBarClaudeWatchdog discards exit status on getppid() == 1 (main.swift:28-29, 101, 115-118)

In killProcessTree (main.swift:26-31), when childPID exits during the 0.5s SIGTERM grace period:

var status: Int32 = 0
while Date() < deadline {
    let rc = waitpid(childPID, &status, WNOHANG)
    if rc == childPID { return }
    usleep(50000)
}

waitpid reaps childPID into the local status variable and returns without propagating status. Then in the main loop (lines 101, 115-118):

var status: Int32 = 0
while true {
    ...
    if getppid() == 1 {
        terminateChild()
        _ = waitpid(globalChildPID, &status, 0)
        Darwin.exit(exitCode(fromWaitStatus: status))
    }

the second waitpid(globalChildPID, &status, 0) fails with -1 (errno = ECHILD), leaving the outer status at 0 (initialized at line 101), so Darwin.exit(exitCode(fromWaitStatus: status)) exits with 0 instead of 143 (128 + SIGTERM).

Live macOS (Darwin) Reproduction
swift -e '
import Darwin
import Foundation

var pid: pid_t = 0
var argv: [UnsafeMutablePointer<CChar>?] = [strdup("/bin/sleep"), strdup("10"), nil]
_ = posix_spawnp(&pid, "/bin/sleep", nil, nil, &argv, environ)

// 1. killProcessTree (main.swift:17-31)
kill(pid, SIGTERM)
var innerStatus: Int32 = 0
for _ in 0..<10 {
    if waitpid(pid, &innerStatus, WNOHANG) == pid { break }
    usleep(50_000)
}
let actualExit = (innerStatus & 0x7F) != 0 ? (128 + (innerStatus & 0x7F)) : ((innerStatus >> 8) & 0xFF)

// 2. Main loop getppid() == 1 branch (main.swift:101, 116-118)
var outerStatus: Int32 = 0
let secondWait = waitpid(pid, &outerStatus, 0)
let secondErrno = errno
let reportedExit = (outerStatus & 0x7F) != 0 ? (128 + (outerStatus & 0x7F)) : ((outerStatus >> 8) & 0xFF)
print("firstWait actualExit=\(actualExit) (128+SIGTERM), secondWait rc=\(secondWait) errno=\(secondErrno) (ECHILD=\(ECHILD)), watchdog reportedExit=\(reportedExit)")
'

Output on macOS:

firstWait actualExit=143 (128+SIGTERM), secondWait rc=-1 errno=10 (ECHILD=10), watchdog reportedExit=0

Suggested Fix:
Have killProcessTree / terminateChild return Int32? with the reaped wait status when waitpid(childPID, &status, WNOHANG) == childPID, and only call blocking waitpid(globalChildPID, &status, 0) at lines 111/117 when terminateChild() returns nil.


Note on Falsified Findings (Filtered Out Before Filing)

We ran this audit using a 4-pass Ponder session (Builder, Skeptic, Dreamer, Empath). On Pass 1, CredentialFileWriter.swift was flagged for potential permission-window / symlink exposure; on Pass 2, Skeptic inspected CredentialFileWriter.writePrivate (Sources/CodexBarCore/CredentialFileWriter.swift:38-74), verified its 0700 staging directory, O_CREAT | O_EXCL 0600 descriptor creation, fchmod(0600) before write, synchronize(), and atomic rename(2), and retracted that finding (op: "delete") before filing.

Generated by Ponder — AI that knows when to speak, and when to shut up.

Dominant language
Swift
Stars
22.8k
Forks
2.2k
Avg merge
21h 49m
Merged PRs (30d)
368

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from steipete/CodexBar

All issues in steipete/CodexBar

Similar issues

More Swift issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.