Skip to content

Commit 84e71c3

Browse files
committed
Improve transparency of stdout and stderr logging
1 parent eca6674 commit 84e71c3

File tree

4 files changed

+114
-53
lines changed

4 files changed

+114
-53
lines changed

Android/android.py

Lines changed: 80 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from asyncio import wait_for
1616
from contextlib import asynccontextmanager
1717
from datetime import datetime, timezone
18+
from enum import IntEnum, auto
1819
from glob import glob
1920
from os.path import abspath, basename, relpath
2021
from pathlib import Path
@@ -61,6 +62,19 @@
6162
hidden_output = []
6263

6364

65+
# Based on android/log.h in the NDK.
66+
class LogPriority(IntEnum):
67+
UNKNOWN = 0
68+
DEFAULT = auto()
69+
VERBOSE = auto()
70+
DEBUG = auto()
71+
INFO = auto()
72+
WARN = auto()
73+
ERROR = auto()
74+
FATAL = auto()
75+
SILENT = auto()
76+
77+
6478
def log_verbose(context, line, stream=sys.stdout):
6579
if context.verbose:
6680
stream.write(line)
@@ -505,47 +519,47 @@ async def logcat_task(context, initial_devices):
505519
pid = await wait_for(find_pid(serial), startup_timeout)
506520

507521
# `--pid` requires API level 24 or higher.
508-
args = [adb, "-s", serial, "logcat", "--pid", pid, "--format", "tag"]
522+
#
523+
# `--binary` mode is used in order to detect which messages end with a
524+
# newline, which most of the other modes don't indicate (except `--format
525+
# long`). For example, every time pytest runs a test, it prints a "." and
526+
# flushes the stream. Each "." becomes a separate log message, but we should
527+
# show them all on the same line.
528+
args = [adb, "-s", serial, "logcat", "--pid", pid, "--binary"]
509529
logcat_started = False
510530
async with async_process(
511-
*args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
531+
*args, stdout=subprocess.PIPE, stderr=None
512532
) as process:
513-
while line := (await process.stdout.readline()).decode(*DECODE_ARGS):
514-
if match := re.fullmatch(r"([A-Z])/(.*)", line, re.DOTALL):
533+
while True:
534+
try:
535+
priority, tag, message = await read_logcat(process.stdout)
515536
logcat_started = True
516-
level, message = match.groups()
517-
else:
518-
# If the regex doesn't match, this is either a logcat startup
519-
# error, or the second or subsequent line of a multi-line
520-
# message. Python won't produce multi-line messages, but other
521-
# components might.
522-
level, message = None, line
537+
except asyncio.IncompleteReadError:
538+
break
523539

524540
# Exclude high-volume messages which are rarely useful.
525541
if context.verbose < 2 and "from python test_syslog" in message:
526542
continue
527543

528544
# Put high-level messages on stderr so they're highlighted in the
529545
# buildbot logs. This will include Python's own stderr.
530-
stream = (
531-
sys.stderr
532-
if level in ["W", "E", "F"] # WARNING, ERROR, FATAL (aka ASSERT)
533-
else sys.stdout
534-
)
535-
536-
# To simplify automated processing of the output, e.g. a buildbot
537-
# posting a failure notice on a GitHub PR, we strip the level and
538-
# tag indicators from Python's stdout and stderr.
539-
for prefix in ["python.stdout: ", "python.stderr: "]:
540-
if message.startswith(prefix):
541-
global python_started
542-
python_started = True
543-
stream.write(message.removeprefix(prefix))
544-
break
546+
stream = sys.stderr if priority > LogPriority.WARN else sys.stdout
547+
548+
# The app's stdout and stderr should be passed through transparently
549+
# to our own corresponding streams.
550+
if tag in ["python.stdout", "python.stderr"]:
551+
global python_started
552+
python_started = True
553+
stream.write(message)
554+
stream.flush()
545555
else:
546556
# Non-Python messages add a lot of noise, but they may
547-
# sometimes help explain a failure.
548-
log_verbose(context, line, stream)
557+
# sometimes help explain a failure. Format them in the same way
558+
# as `logcat --format tag`.
559+
formatted = f"{priority.name[0]}/{tag}: {message}"
560+
if not formatted.endswith("\n"):
561+
formatted += "\n"
562+
log_verbose(context, formatted, stream)
549563

550564
# If the device disconnects while logcat is running, which always
551565
# happens in --managed mode, some versions of adb return non-zero.
@@ -556,6 +570,44 @@ async def logcat_task(context, initial_devices):
556570
raise CalledProcessError(status, args)
557571

558572

573+
# Read one binary log message from the given StreamReader. The message format is
574+
# described at https://android.stackexchange.com/a/74660. All supported versions
575+
# of Android use format version 2 or later.
576+
async def read_logcat(stream):
577+
async def read_bytes(size):
578+
return await stream.readexactly(size)
579+
580+
async def read_int(size):
581+
return int.from_bytes(await read_bytes(size), "little")
582+
583+
payload_len = await read_int(2)
584+
if payload_len < 2:
585+
# 1 byte for priority, 1 byte for null terminator of tag.
586+
raise ValueError(f"payload length {payload_len} is too short")
587+
588+
header_len = await read_int(2)
589+
if header_len < 4:
590+
raise ValueError(f"header length {header_len} is too short")
591+
await read_bytes(header_len - 4) # Ignore other header fields.
592+
593+
priority_int = await read_int(1)
594+
try:
595+
priority = LogPriority(priority_int)
596+
except ValueError:
597+
priority = LogPriority.UNKNOWN
598+
599+
payload_fields = (await read_bytes(payload_len - 1)).split(b"\0")
600+
if len(payload_fields) < 2:
601+
raise ValueError(
602+
f"payload {payload!r} does not contain at least 2 "
603+
f"null-separated fields"
604+
)
605+
tag, message, *_ = [
606+
field.decode(*DECODE_ARGS) for field in payload_fields
607+
]
608+
return priority, tag, message
609+
610+
559611
def stop_app(serial):
560612
run([adb, "-s", serial, "shell", "am", "force-stop", APP_ID], log=False)
561613

Android/testbed/app/build.gradle.kts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,13 @@ android {
9494
}
9595

9696
// This controls the API level of the maxVersion managed emulator, which is used
97-
// by CI and cibuildwheel. 34 takes up too much disk space (#142289), 35 has
98-
// issues connecting to the internet (#142387), and 36 and later are not
99-
// available as aosp_atd images yet.
100-
targetSdk = 33
97+
// by CI and cibuildwheel.
98+
// * 33 has excessive buffering in the logcat client
99+
// (https://cs.android.com/android/_/android/platform/system/logging/+/d340721894f223327339010df59b0ac514308826).
100+
// * 34 consumes too much disk space on GitHub Actions (#142289).
101+
// * 35 has issues connecting to the internet (#142387).
102+
// * 36 and later are not available as aosp_atd images yet.
103+
targetSdk = 32
101104

102105
versionCode = 1
103106
versionName = "1.0"

Lib/_android_support.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ def write(self, prio, tag, message):
168168
# message.
169169
message = message.replace(b"\x00", b"\xc0\x80")
170170

171+
# On API level 30 and higher, Logcat will strip any number of leading
172+
# newlines. This is visible in all `logcat` modes, even --binary. Work
173+
# around this by adding a leading space, which shouldn't make any
174+
# difference to the log's usability.
175+
if message.startswith(b"\n"):
176+
message = b" " + message
177+
171178
with self._lock:
172179
now = time()
173180
self._bucket_level += (

Lib/test/test_android.py

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import io
2-
import platform
32
import queue
43
import re
54
import subprocess
@@ -17,8 +16,6 @@
1716
if sys.platform != "android":
1817
raise unittest.SkipTest("Android-specific")
1918

20-
api_level = platform.android_ver().api_level
21-
2219
# (name, level, fileno)
2320
STREAM_INFO = [("stdout", "I", 1), ("stderr", "W", 2)]
2421

@@ -179,14 +176,18 @@ def write(s, lines=None, *, write_len=None):
179176

180177
# Multi-line messages. Avoid identical consecutive lines, as
181178
# they may activate "chatty" filtering and break the tests.
182-
write("\nx", [""])
179+
#
180+
# Additional spaces will appear in the output where necessary to
181+
# protect leading newlines.
182+
write("\nx", [" "])
183183
write("\na\n", ["x", "a"])
184-
write("\n", [""])
184+
write("\n", [" "])
185+
write("\n\n", [" ", " "])
185186
write("b\n", ["b"])
186-
write("c\n\n", ["c", ""])
187+
write("c\n\n", ["c", " "])
187188
write("d\ne", ["d"])
188189
write("xx", [])
189-
write("f\n\ng", ["exxf", ""])
190+
write("f\n\ng", ["exxf", " "])
190191
write("\n", ["g"])
191192

192193
# Since this is a line-based logging system, line buffering
@@ -197,15 +198,16 @@ def write(s, lines=None, *, write_len=None):
197198
# However, buffering can be turned off completely if you want a
198199
# flush after every write.
199200
with self.reconfigure(stream, write_through=True):
200-
write("\nx", ["", "x"])
201-
write("\na\n", ["", "a"])
202-
write("\n", [""])
201+
write("\nx", [" ", "x"])
202+
write("\na\n", [" ", "a"])
203+
write("\n", [" "])
204+
write("\n\n", [" ", " "])
203205
write("b\n", ["b"])
204-
write("c\n\n", ["c", ""])
206+
write("c\n\n", ["c", " "])
205207
write("d\ne", ["d", "e"])
206208
write("xx", ["xx"])
207-
write("f\n\ng", ["f", "", "g"])
208-
write("\n", [""])
209+
write("f\n\ng", ["f", " ", "g"])
210+
write("\n", [" "])
209211

210212
# "\r\n" should be translated into "\n".
211213
write("hello\r\n", ["hello"])
@@ -325,19 +327,16 @@ def write(b, lines=None, *, write_len=None):
325327
# currently use `logcat -v tag`, which shows each line as if it
326328
# was a separate log entry, but strips a single trailing
327329
# newline.
328-
#
329-
# On newer versions of Android, all three of the above tools (or
330-
# maybe Logcat itself) will also strip any number of leading
331-
# newlines.
332-
write(b"\nx", ["", "x"] if api_level < 30 else ["x"])
333-
write(b"\na\n", ["", "a"] if api_level < 30 else ["a"])
334-
write(b"\n", [""])
330+
write(b"\nx", [" ", "x"])
331+
write(b"\na\n", [" ", "a"])
332+
write(b"\n", [" "])
333+
write(b"\n\n", [" ", ""])
335334
write(b"b\n", ["b"])
336335
write(b"c\n\n", ["c", ""])
337336
write(b"d\ne", ["d", "e"])
338337
write(b"xx", ["xx"])
339338
write(b"f\n\ng", ["f", "", "g"])
340-
write(b"\n", [""])
339+
write(b"\n", [" "])
341340

342341
# "\r\n" should be translated into "\n".
343342
write(b"hello\r\n", ["hello"])

0 commit comments

Comments
 (0)