Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 131 additions & 23 deletions templates/family-root/scripts/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@
fails is named with that command's exit code, read from make's
`Error <code>` line, since make exits 2 for any command that fails,
so a member's finding (1) and its refusal (2) are told apart, and
from a failure of make's own, which prints no such line (#839).
from a failure of make's own, which prints no such line (#839). Where
the member's make runs another, the code is the innermost command's,
as far as make's lines tell it (`MakeErrors` says where they do not);
make's stderr reaches the terminal through this run, and `run_make`
says what that changes for a member (#884).

(c) EXITS 0 unless a member's own command failed, or (a) left a member not
checked out that no credential would check out, or found a line or a
Expand Down Expand Up @@ -998,44 +1002,148 @@ def run_member_target(root: Path, row: dict, target: str) -> str | None:
"message above says what failed)")


#: The line GNU make prints where a command of the target exits non-zero,
#: `make: *** [Makefile:44: bootstrap] Error 1` (`make[1]:` in a make run by
#: make, `*** [bootstrap] Error 1` before make 4.0): the command's own exit
#: code, which make's does not carry, since make exits 2 for any command
#: that fails (#839).
MAKE_ERROR_RE = re.compile(rb"\*\*\* \[[^\n]*\] Error (\d+)\s*$")
#: What GNU make prints on stderr as a target fails, after its own name
#: and, in a make run by make, its depth (`make[1]:`): `*** [Makefile:44:
#: bootstrap] Error 1` where a command exits non-zero (`*** [bootstrap]
#: Error 1` before make 4.0), with the command's own exit code, which
#: make's does not carry, since make exits 2 for any command that fails
#: (#839); `*** No rule to make target ...`, or `*** [Makefile:44:
#: bootstrap] Killed`, where make fails on its own or a signal ends what it
#: ran, with none; and `[Makefile:44: bootstrap] Error 2 (ignored)`, with no
#: `***`, where a `-` before a command lets it fail with a make's code
#: (#884).
MAKE_FAILED_RE = re.compile(
rb"(?:\[(\d+)\])?: (?:\*\*\* (?:\[[^\n]*\] Error (\d+)\s*$)?"
rb"|\[[^\n]*\] Error 2 \((ignored)\)\s*$)")
#: The bytes `MakeErrors` keeps of a line of make's stderr not yet ended:
#: an `Error <code>` line ends there, so the end of a line is enough.
LINE_KEPT = 4096


class MakeErrors:
"""What `run_make` reads of make's stderr, one line at a time as it
passes (#884): `code`, that of the last `Error <code>` line make printed
for a command of the member's (`MAKE_FAILED_RE`, its last match on a
line, since a member's own text can come before make's on one), or
None.

Read as it passes, an `Error` line is never lost to what follows it,
as one was to more than 4096 bytes after it (`make -k`) where only the
last 4096 were kept. An `Error 2` at a shallower depth than the last
`***` line before it, whatever lines come between, is taken for a
make's parent saying it failed, since make exits 2 for any failure,
and is passed over: where the member's make runs another (`$(MAKE)
inner`), the code is the innermost command's, and where the inner make
failed on its own there is none, as for make's own failure. Where a
`-` let that make fail (`Error 2 (ignored)`), the depth is the
parent's again, so an `Error 2` of the parent's own after it is named.

WHERE THE CODE IS NOT THE INNERMOST COMMAND'S, since make's lines do
not tell it:
- a recipe that runs a make, lets its failure pass in the shell (`||
true`, or `;`), or with a `-` in a make run with `-s`, which then
prints no `(ignored)` line, and exits 2 itself: its `Error 2` is
read as that make's report, and the inner command's code is named;
- an inner make run with `env -u MAKELEVEL`, which prints its parent's
depth: the parent's `Error 2` is named;
- under `-j`, a job of the parent's that fails while the inner make is
still running: its line comes between, and the parent's `Error 2` is
named;
- a member's own line shaped like a deeper make's (`x[3]: *** y`)
before it exits 2 itself: its `Error 2` is passed over."""

def __init__(self) -> None:
self.code: int | None = None
self.depth = -1
self.unended = b""

def read(self, chunk: bytes) -> None:
"""`chunk` of make's stderr: each line it ends, read
(`said`)."""
*ended, unended = (self.unended + chunk).split(b"\n")
for line in ended:
self.said(line)
self.unended = unended[-LINE_KEPT:]

def said(self, line: bytes) -> None:
"""One line of make's stderr, read for its last `MAKE_FAILED_RE`."""
found = MAKE_FAILED_RE.findall(line)
if not found:
return
depth_said, code, ignored = found[-1]
depth = int(depth_said or 0)
if ignored:
self.depth = min(self.depth, depth)
return
reports_a_make = code == b"2" and depth < self.depth

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not discard Error 2 after a handled submake failure

When a member recipe handles a recursive failure (for example, $(MAKE) -s inner || true) and a later command genuinely exits 2, the inner error raises self.depth but produces no parent Error 2 because the shell handled it. The later top-level Error 2 then has a lower depth and is discarded here, leaving the stale inner code (such as 1), so the family summary mislabels an actual member refusal as a finding. Suppression needs to be tied to an unhandled submake failure rather than any previously observed deeper error.

Useful? React with 👍 / 👎.

self.depth = depth
if code and not reports_a_make:
self.code = int(code)


def run_make(make: str, target: str, cwd: Path) -> tuple[int, int | None]:
"""(b) `make <target>` in a member, as `(make's exit code, the exit code
of the member's command it names)`, the second None where make printed
no `MAKE_ERROR_RE` line (#839).
of the member's command it names)`, the second None where make names
none (`MakeErrors`, #839, #884).

MAKE'S OWN CODE TELLS NOTHING APART. GNU make exits 2 for any command
that fails, measured (GNU Make 4.3) for a command exiting 1, 2, 3 or
126, and 2 for a failure of its own (`No rule to make target`), so the
line said `make bootstrap exited 2` for the member's own `bootstrap
FAILED` (1, a finding), for a refusal (2) and for make's own failure
alike (w764b, for lane 4). The command's code is the one make names
in its `Error <code>` line, the last on stderr, so make's stderr is
passed on to this run's own as it comes and that line read from the
end of it; a make that prints none, one of its own failures, a command
killed by a signal, or a make speaking another language, leaves make's
code alone. The member's command is never run around make: another
target than `bootstrap` (`--members-make validate`) has no script of
its own to call, as `make siblings` calls a member's
`scripts/bootstrap.py`."""
in its `Error <code>` line, so make's stderr is passed on to this
run's own as it comes, and read a line at a time as it passes: the
last such line names it, or, where the member's make runs another,
the innermost command's line (`MakeErrors`). A make that prints none,
one of its own failures, or a make speaking another language, leaves
make's code alone. So does a command killed by a signal where what
make started is what was killed: a command make runs with no shell,
on a line with nothing a shell must read; the shell a `kill -KILL $$`
kills; or a command bash runs in its own place, a line's last, a line
of one command included. Make then prints the signal's name, `Killed`
or `Terminated`, and no code. Where the shell outlives the command, as
dash, `/bin/sh` on Debian and Ubuntu, does for every command, and bash
for one that is not a line's last, the shell prints the signal's name
itself and exits 128 and the signal's number, 137 for SIGKILL and 143
for SIGTERM, which make names as the member's command's code; where
the line goes on after it, the shell exits as the rest of the line
does (#884). The member's command is never run around make:
another target than `bootstrap` (`--members-make validate`) has no
script of its own to call, as `make siblings` calls a member's
`scripts/bootstrap.py`.

WHAT PASSING STDERR ON CHANGES (#884), measured against a make that
writes to this run's stderr, a terminal, itself:
- The member's commands write stderr to a pipe, not a terminal, so a
test of `isatty(2)` reads false: git shows no progress meter, and a
command that colours or prompts by that test does neither. Stdin
and stdout stay this run's own.
- The member's stdout and stderr lines can come out of order: its
stderr passes through this run, a little after the stdout written
beside it.
- A process a command leaves running with stderr open holds the run
until it closes it or exits, since the pipe ends only then, and what
it writes until then is passed on. Reading only until make exits
would end the run at once, but that process's next write to stderr
would meet a closed pipe and end it (SIGPIPE), unsaid. A recipe that
starts one gives it a stderr of its own (`2>log`).
- The bytes pass on as written, never decoded. On Windows a member's
Python writes a pipe in the ANSI code page, and this run's stderr, a
console, reads them as UTF-8, so non-ASCII text comes out garbled
(read, not run).
Giving make the terminal back would lose its `Error <code>` line,
which nothing else make writes carries (`--debug=j` says only
"Reaping losing child"), and the line would say `exited 2` for every
failure again."""
proc = subprocess.Popen([make, target], cwd=str(cwd),
stderr=subprocess.PIPE)
tail = b""
errors = MakeErrors()
for chunk in iter(lambda: proc.stderr.read1(65536), b""):
passed_on(chunk)
tail = (tail + chunk)[-4096:]
errors.read(chunk)
proc.stderr.close()
returncode = proc.wait()
found = [MAKE_ERROR_RE.search(line) for line in tail.splitlines()]
named = [int(match.group(1)) for match in found if match]
return returncode, (named[-1] if named else None)
return proc.wait(), errors.code


def passed_on(chunk: bytes) -> None:
Expand Down
Loading
Loading