Skip to content

6.0.0

@tannerdsilva tannerdsilva tagged this 16 Oct 16:59
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.
Assets 2
Loading