a launch's child that itself spawned through swiftslash ran the same spawn
path, which unconditionally requested a new process group. the nested launch
therefore formed a SECOND group that the outer teardown could not reach: the
grandchild was reparented and kept working after the caller had already
reported failure. the caller cannot fix this — it cannot see grandchildren
reliably — so placement becomes the spawner's decision, and this makes it
part of the spawn contract rather than a spawn detail.
placement is one typed value per launch:
.own the child leads a new group. unchanged behaviour, and the
resolved default outside a launch
.inherited the child joins the launcher's group, so an outer teardown
cascades through it, transitively
.detached the child starts a new session (setsid) and outlives its launch
the teardown target follows the placement: a child that leads a group is
reachable as a group (kill(-pid)), while a child that leads none is signalled
by pid alone, because a negated pid would address the launcher's own group —
which, for an .inherited child, is the caller's group.
the default resolves from the process relationship rather than from ambient
configuration. a launch leaves a fixed-size record in a pipe and installs the
read end in its child at file descriptor 3; the child reads it at first need
and caches the answer, since the read consumes the record and a process's
enclosing launch cannot change over its lifetime. the record is VERIFIED, not
merely recognised: the placement it names has to agree with where the process
actually sits, so a stale or forged descriptor cannot talk a process into a
placement nobody gave it. a launch holds no descriptor for its own lifetime,
and the probe never blocks (see below).
also:
- signal(_:to:) closes the pid-vs-group asymmetry between the cancellation
path and the public signal API. cancellation still delivers one signal and
does not escalate; SIGKILL stays the caller's decision, now documented as a
contract instead of left implicit.
- reaping is pinned on every exit path — normal, non-zero, a launch that
cannot spawn, cancellation, and cancellation followed by escalation — by
asserting ECHILD per pid. asserted per pid and not with waitpid(-1), which
would reap a child the launch's own reaper is about to collect and so turn
the probe into an interference.
- CurrentEnvironment.launchIdentity() exposes the enclosing launch, or nil
outside one.
- docs: new "Process Groups and Detachment" article; Task Cancellation's
process-group section corrected, since it claimed every child leads its own
group and this change makes that false; Versioning gains the v6 series and
a migration table.
two defects in this change were found only by running the FULL suite rather
than a filtered subset. each looks correct in isolation, so both are recorded:
1. the probe used a bare read on descriptor 3 and only CLAIMED it could not
block. that holds when 3 carries a real record; for any other live
descriptor — a socket, a pipe whose writer is still alive, a terminal —
read() blocked forever and the whole suite hung at the first ChildProcess,
with no diagnostic and no cpu. the probe now polls with a zero timeout and
never waits, so a process holding a descriptor of its own at that number
degrades to .own exactly like one that closed it. regression test added,
with a fixture mode that holds an open, empty pipe there.
2. the descriptor's dup2 was installed BEFORE the channel bindings. file
actions run in order in the child, and the reserved number is frequently a
CHANNEL end — a pipe takes the lowest free numbers, so a freed 3 becomes
one — so the descriptor overwrote that end before the binding had read it.
the child's stdio was silently wired to the descriptor: 254 exit-code
assertions failed, every child exiting 1 after read() saw EOF, and a
byo-descriptor test reported the wrong error because the launch's own pipe
had taken the number the test had just released. both fall out of
restoring the library's own stated invariant — no pipe of its own before
caller descriptors are validated — by creating the descriptor after that
validation and installing it LAST.
gates, measured, counts read from redirected logs:
- macOS: 91 tests in 16 suites, 0 issues. the baseline this series started
from was 72 tests in 16 suites with exactly one red — the nested-launch
escape pinned as a failing test — and 72 - 1 + 20 = 91.
- linux (x86_64, glibc 2.39, swift 6.4, -j 2): 75 tests in 16 suites green
with 0 build errors, covering placement, cascade and detachment. that is
also the evidence that posix_spawn's SETSID is honoured as 0x80 there. the
descriptor mechanism has NOT been gated on linux yet: the rsync-and-run
step needs an approval that timed out mid-session.
- sensitivity, so the gates are shown to test something: ignoring the signal
recipient fails exactly the group-scoped test and nothing else; forcing the
descriptor probe to find nothing fails exactly the three
descriptor-dependent tests, while the degradation and verification tests
stay green. the tree hash is identical before and after each check.
- hygiene: no new dependency, and no dispatch queue, lock, semaphore, or
Process() in any touched file.