blob: 2f84a9f9f6902c463d9b0e66240fd5e406c39a79 [file] [edit]
#!/usr/bin/env python3
# Copyright (C) 2026 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Local HTTP integration tests. Run with --shell PATH_TO_TRACE_PROCESSOR_SHELL.
Requires curl, llvm-symbolizer, and the downloaded test_symbolizer_binary
fixture. No public server is contacted.
"""
import argparse
import http.server
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import threading
import time
import unittest
ROOT = Path(__file__).resolve().parents[1]
BUILD_ID = 'f7558cfad3e9e2ff6cafcb0fd8442a322210ba6e'
SHELL = None
class DebuginfodTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.cache = self.root / 'cache'
self.requests = []
binary = (ROOT / 'test/data/test_symbolizer_binary').read_bytes()
requests = self.requests
class Handler(http.server.BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
requests.append(self.path)
if self.path.startswith('/missing/'):
self.send_error(404)
return
if self.path.startswith('/slow/'):
time.sleep(2)
if self.path.startswith('/redirect/'):
self.send_response(302)
self.send_header('Location', self.path.replace('/redirect/', '/ok/'))
self.end_headers()
return
data = b'not an ELF file' if self.path.startswith('/bad/') else binary
if self.path.startswith('/wrong/'):
data = binary.replace(bytes.fromhex(BUILD_ID), b'\x00' * 20)
self.send_response(200)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
try:
self.wfile.write(data)
except (BrokenPipeError, ConnectionResetError):
pass
self.server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
self.addCleanup(self.server.server_close)
self.addCleanup(self.server.shutdown)
thread = threading.Thread(target=self.server.serve_forever, daemon=True)
thread.start()
self.url = 'http://127.0.0.1:%d' % self.server.server_port
self.env = dict(os.environ)
for name in ('DEBUGINFOD_URLS', 'DEBUGINFOD_CACHE_PATH',
'LLVM_SYMBOLIZER_OPTS', 'PERFETTO_BINARY_PATH',
'BREAKPAD_SYMBOL_DIR'):
self.env.pop(name, None)
self.env['NO_PROXY'] = '127.0.0.1'
self.env['no_proxy'] = '127.0.0.1'
build_id = ''.join('\\%03o' % byte for byte in bytes.fromhex(BUILD_ID))
source = self.root / 'trace.textproto'
source.write_text('''packet { clock_snapshot {
clocks { clock_id: 3 timestamp: 0 }
clocks { clock_id: 5 timestamp: 0 }
clocks { clock_id: 6 timestamp: 0 }
} }
packet {
trusted_packet_sequence_id: 1
incremental_state_cleared: true
thread_descriptor { pid: 1 tid: 1 }
interned_data {
build_ids { iid: 1 str: "%s" }
mapping_paths { iid: 1 str: "remote-only" }
mappings { iid: 1 build_id: 1 path_string_ids: 1
start: 1048576 end: 1114112 load_bias: 0 exact_offset: 4096 }
frames { iid: 1 mapping_id: 1 rel_pc: 4400 }
callstacks { iid: 1 frame_ids: 1 }
}
}
packet {
trusted_packet_sequence_id: 1
streaming_profile_packet { callstack_iid: 1 timestamp_delta_us: 1 }
}''' % build_id)
self.trace = self.root / 'trace.pftrace'
self.run_shell('util', 'text_to_binary', str(source), str(self.trace))
def run_shell(self, *args, success=True):
result = subprocess.run([SHELL, *args],
env=self.env,
capture_output=True,
timeout=15)
if success:
self.assertEqual(result.returncode, 0, result.stderr.decode())
else:
self.assertNotEqual(result.returncode, 0)
return result
def bundle(self, *args):
return self.run_shell('bundle', '--no-auto-symbol-paths',
'--no-auto-proguard-maps', '--debuginfod-cache-path',
str(self.cache), *args, str(self.trace),
str(self.root / 'bundle.tar'))
def assert_symbolized(self):
result = self.run_shell('query', '-q', str(self.root / 'bundle.tar'),
'SELECT name FROM stack_profile_symbol')
self.assertIn(b'TestFunctionToSymbolize', result.stdout)
def test_remote_only_and_cache_reuse(self):
result = self.bundle('--debuginfod', '--debuginfod-urls', self.url + '/ok')
self.assertIn(b'1 downloaded', result.stderr)
self.assert_symbolized()
self.assertEqual(self.requests, ['/ok/buildid/' + BUILD_ID + '/debuginfo'])
self.requests.clear()
result = self.bundle('--debuginfod', '--debuginfod-urls', self.url + '/ok')
self.assertIn(b'1 cache hits', result.stderr)
self.assertEqual(self.requests, [])
def test_disabled_warns_even_when_quiet(self):
self.env['DEBUGINFOD_URLS'] = self.url + '/ok'
result = self.bundle('-q')
self.assertIn(b'ignored', result.stderr)
self.assertIn(b'--debuginfod', result.stderr)
self.assertEqual(self.requests, [])
self.assertFalse(self.cache.exists())
def test_cli_overrides_environment_and_llvm_options(self):
self.env['DEBUGINFOD_URLS'] = self.url + '/missing'
self.env['DEBUGINFOD_CACHE_PATH'] = str(self.root / 'wrong-cache')
self.env[
'LLVM_SYMBOLIZER_OPTS'] = '--output-style=GNU --debuginfod --invalid-option'
result = self.bundle('--debuginfod', '--debuginfod-urls',
self.url + '/redirect')
self.assertIn(b'LLVM_SYMBOLIZER_OPTS is ignored', result.stderr)
self.assert_symbolized()
self.assertFalse((self.root / 'wrong-cache').exists())
self.assertFalse(any('/missing/' in path for path in self.requests))
def test_invalid_response_falls_back(self):
result = self.bundle('--debuginfod', '--debuginfod-urls',
self.url + '/bad\t' + self.url + '/ok', '--verbose')
self.assertIn(b'invalid debug file', result.stderr)
self.assert_symbolized()
self.assertEqual(len(self.requests), 2)
self.assertEqual(list(self.cache.rglob('*.tmp.*')), [])
def test_wrong_build_id_falls_back(self):
self.bundle('--debuginfod', '--debuginfod-urls',
self.url + '/wrong ' + self.url + '/ok')
self.assert_symbolized()
self.assertEqual(len(self.requests), 2)
def test_invalid_cache_is_replaced(self):
entry = self.cache / BUILD_ID / 'debuginfo'
entry.parent.mkdir(parents=True)
entry.write_bytes(b'incomplete')
self.bundle('--debuginfod', '--debuginfod-urls', self.url + '/ok')
self.assert_symbolized()
self.assertEqual(len(self.requests), 1)
self.assertEqual(entry.read_bytes(),
(ROOT / 'test/data/test_symbolizer_binary').read_bytes())
def test_curl_config_cannot_add_requests(self):
(self.root / '.curlrc').write_text('url = "' + self.url + '/missing"')
self.env['CURL_HOME'] = str(self.root)
self.bundle('--debuginfod', '--debuginfod-urls', self.url + '/ok')
self.assert_symbolized()
self.assertEqual(self.requests, ['/ok/buildid/' + BUILD_ID + '/debuginfo'])
def test_failed_download_does_not_publish_cache_entry(self):
result = self.bundle('--debuginfod', '--debuginfod-urls', self.url + '/bad')
self.assertIn(b'1 unavailable', result.stderr)
self.assertFalse((self.cache / BUILD_ID / 'debuginfo').exists())
self.assertEqual(list(self.cache.rglob('*.tmp.*')), [])
def test_local_result_prevents_download(self):
local = self.root / 'local'
local.mkdir()
shutil.copy(ROOT / 'test/data/test_symbolizer_binary', local / 'binary')
self.bundle('--debuginfod', '--debuginfod-urls', self.url + '/ok',
'--symbol-paths', str(local))
self.assert_symbolized()
self.assertEqual(self.requests, [])
def test_stall_timeout_falls_back(self):
result = self.bundle('--debuginfod', '--debuginfod-stall-timeout', '1',
'--debuginfod-urls',
self.url + '/slow ' + self.url + '/ok', '--verbose')
self.assertIn(b'curl exit 28', result.stderr)
self.assert_symbolized()
@unittest.skipUnless(hasattr(os, 'openpty'), 'requires a pseudo-terminal')
def test_progress_controls(self):
self.env['TERM'] = 'xterm'
for flags in ([], ['--no-progress'], ['-q']):
shutil.rmtree(self.cache, ignore_errors=True)
master, slave = os.openpty()
try:
result = subprocess.run([
SHELL, 'bundle', '--no-auto-symbol-paths',
'--no-auto-proguard-maps', '--debuginfod', '--debuginfod-urls',
self.url + '/ok', '--debuginfod-cache-path',
str(self.cache), *flags,
str(self.trace),
str(self.root / 'bundle.tar')
],
env=self.env,
stdout=subprocess.PIPE,
stderr=slave,
timeout=15)
os.close(slave)
slave = None
output = b''
while True:
try:
data = os.read(master, 4096)
except OSError:
break
if not data:
break
output += data
self.assertEqual(result.returncode, 0, output.decode())
self.assertEqual(b'Debuginfod: fetching build ID' in output, not flags)
if flags == ['-q']:
self.assertEqual(output, b'')
finally:
os.close(master)
if slave is not None:
os.close(slave)
def test_symbolize_utility_and_traceconv(self):
flags = [
'--debuginfod', '--debuginfod-urls', self.url + '/ok',
'--debuginfod-cache-path',
str(self.cache), '--quiet'
]
result = self.run_shell('util', 'symbolize', *flags, str(self.trace))
self.assertIn(b'TestFunctionToSymbolize', result.stdout)
self.assertEqual(result.stderr, b'')
result = subprocess.run([
str(Path(SHELL).with_name('traceconv')), 'symbolize', *flags,
str(self.trace)
],
env=self.env,
capture_output=True,
timeout=15)
self.assertEqual(result.returncode, 0, result.stderr.decode())
self.assertIn(b'TestFunctionToSymbolize', result.stdout)
self.assertEqual(result.stderr, b'')
def test_query_and_classic_interface_without_local_binaries(self):
for args in [('query', '-q', str(self.trace),
'SELECT name FROM stack_profile_symbol'),
('--quiet', '-Q', 'SELECT name FROM stack_profile_symbol',
str(self.trace))]:
result = self.run_shell(*args, '--debuginfod', '--debuginfod-urls',
self.url + '/ok', '--debuginfod-cache-path',
str(self.cache))
self.assertIn(b'TestFunctionToSymbolize', result.stdout)
self.assertEqual(result.stderr, b'', result.stderr.decode())
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--shell', required=True)
args, remaining = parser.parse_known_args()
SHELL = str(Path(args.shell).resolve())
unittest.main(argv=[__file__, *remaining])