Skip to content

Commit 5d0fa7a

Browse files
[3.15] gh-145306: Fix browser open after empty export (GH-150017) (#152447)
gh-145306: Fix browser open after empty export (GH-150017) (cherry picked from commit 860f8a5) Co-authored-by: ivonastojanovic <80911834+ivonastojanovic@users.noreply.github.com>
1 parent 61bb57f commit 5d0fa7a

10 files changed

Lines changed: 73 additions & 13 deletions

File tree

Lib/profiling/sampling/binary_collector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def export(self, filename=None):
9494
filename: Ignored (binary files are written incrementally)
9595
"""
9696
self._writer.finalize()
97+
return True
9798

9899
@property
99100
def total_samples(self):

Lib/profiling/sampling/cli.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,9 @@ def __call__(self, parser, namespace, values, option_string=None):
117117
"binary": BinaryCollector,
118118
}
119119

120+
BROWSER_COMPATIBLE_FORMATS = ("flamegraph", "diff_flamegraph", "heatmap")
121+
122+
120123
def _setup_child_monitor(args, parent_pid):
121124
# Build CLI args for child profilers (excluding --subprocesses to avoid recursion)
122125
child_cli_args = _build_child_profiler_args(args)
@@ -528,8 +531,12 @@ def _add_format_options(parser, include_compression=True, include_binary=True):
528531
output_group.add_argument(
529532
"--browser",
530533
action="store_true",
531-
help="Automatically open HTML output (flamegraph, heatmap) in browser. "
532-
"When using `--subprocesses`, only the main process opens the browser",
534+
help=(
535+
"Automatically open HTML output "
536+
f"({', '.join('--' + f.replace('_', '-') for f in BROWSER_COMPATIBLE_FORMATS)}) "
537+
"in browser. "
538+
"When using `--subprocesses`, only the main process opens the browser"
539+
),
533540
)
534541

535542

@@ -789,13 +796,12 @@ def progress_callback(current, total):
789796
args.outfile
790797
or _generate_output_filename(args.format, os.getpid())
791798
)
792-
collector.export(filename)
799+
export_ok = collector.export(filename)
793800

794801
# Auto-open browser for HTML output if --browser flag is set
795802
if (
796-
args.format in (
797-
'flamegraph', 'diff_flamegraph', 'heatmap'
798-
)
803+
export_ok
804+
and args.format in BROWSER_COMPATIBLE_FORMATS
799805
and getattr(args, 'browser', False)
800806
):
801807
_open_in_browser(filename)
@@ -840,10 +846,14 @@ def _handle_output(collector, args, pid, mode):
840846
filename = os.path.join(args.outfile, _generate_output_filename(args.format, pid))
841847
else:
842848
filename = args.outfile or _generate_output_filename(args.format, pid)
843-
collector.export(filename)
849+
export_ok = collector.export(filename)
844850

845851
# Auto-open browser for HTML output if --browser flag is set
846-
if args.format in ('flamegraph', 'diff_flamegraph', 'heatmap') and getattr(args, 'browser', False):
852+
if (
853+
export_ok
854+
and args.format in BROWSER_COMPATIBLE_FORMATS
855+
and getattr(args, 'browser', False)
856+
):
847857
_open_in_browser(filename)
848858

849859

Lib/profiling/sampling/collector.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,11 @@ def collect_failed_sample(self):
163163

164164
@abstractmethod
165165
def export(self, filename):
166-
"""Export collected data to a file."""
166+
"""Export collected data.
167+
168+
Returns:
169+
bool: True if output was generated, False if there was no data to export.
170+
"""
167171

168172
@staticmethod
169173
def _filter_internal_frames(frames):

Lib/profiling/sampling/gecko_collector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,7 @@ def spin():
756756
print(
757757
f"Open in Firefox Profiler: https://profiler.firefox.com/"
758758
)
759+
return True
759760

760761
def _build_marker_schema(self):
761762
"""Build marker schema definitions for Firefox Profiler."""

Lib/profiling/sampling/heatmap_collector.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ def export(self, output_path):
598598
"""
599599
if not self.file_samples:
600600
print("Warning: No heatmap data to export")
601-
return
601+
return False
602602

603603
try:
604604
output_dir = self._prepare_output_directory(output_path)
@@ -612,6 +612,7 @@ def export(self, output_path):
612612
self._generate_index_html(output_dir / 'index.html', file_stats)
613613

614614
self._print_export_summary(output_dir, file_stats)
615+
return True
615616

616617
except Exception as e:
617618
print(f"Error: Failed to export heatmap: {e}")

Lib/profiling/sampling/jsonl_collector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ def export(self, filename):
165165
)
166166
self._write_message(output, self._build_end_record())
167167
print(f"JSONL profile written to {filename}")
168+
return True
168169

169170
def _build_meta_record(self):
170171
record = {

Lib/profiling/sampling/pstats_collector.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def collect(self, stack_frames, timestamps_us=None):
6363
def export(self, filename):
6464
self.create_stats()
6565
self._dump_stats(filename)
66+
return True
6667

6768
def _dump_stats(self, file):
6869
stats_with_marker = dict(self.stats)

Lib/profiling/sampling/stack_collector.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ def export(self, filename):
6464
for stack, count in lines:
6565
f.write(f"{stack} {count}\n")
6666
print(f"Collapsed stack output written to {filename}")
67+
return True
6768

6869

6970
class FlamegraphCollector(StackTraceCollector):
@@ -161,14 +162,15 @@ def export(self, filename):
161162
print(
162163
"Warning: No functions found in profiling data. Check if sampling captured any data."
163164
)
164-
return
165+
return False
165166

166167
html_content = self._create_flamegraph_html(flamegraph_data)
167168

168169
with open(filename, "w", encoding="utf-8") as f:
169170
f.write(html_content)
170171

171172
print(f"Flamegraph saved to: {filename}")
173+
return True
172174

173175
@staticmethod
174176
@functools.lru_cache(maxsize=None)

Lib/test/test_profiling/test_sampling_profiler/test_cli.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import sys
88
import tempfile
99
import unittest
10+
from types import SimpleNamespace
1011
from unittest import mock
1112

1213
try:
@@ -26,6 +27,7 @@
2627
FORMAT_EXTENSIONS,
2728
_create_collector,
2829
_generate_output_filename,
30+
_handle_output,
2931
main,
3032
)
3133
from profiling.sampling.constants import (
@@ -727,6 +729,26 @@ def test_async_aware_flag_defaults_to_running(self):
727729
call_kwargs = mock_sample.call_args[1]
728730
self.assertEqual(call_kwargs.get("async_aware"), "running")
729731

732+
def test_handle_output_browser_not_opened_when_export_fails(self):
733+
for format_type in ("flamegraph", "diff_flamegraph", "heatmap"):
734+
with self.subTest(format=format_type):
735+
collector = mock.MagicMock()
736+
collector.export.return_value = False
737+
args = SimpleNamespace(
738+
format=format_type,
739+
outfile="profile.html",
740+
browser=True,
741+
)
742+
743+
with (
744+
mock.patch("profiling.sampling.cli.os.path.isdir", return_value=False),
745+
mock.patch("profiling.sampling.cli._open_in_browser") as mock_open,
746+
):
747+
_handle_output(collector, args, pid=12345, mode=0)
748+
749+
collector.export.assert_called_once_with("profile.html")
750+
mock_open.assert_not_called()
751+
730752
def test_async_aware_with_async_mode_all(self):
731753
"""Test --async-aware with --async-mode all."""
732754
test_args = ["profiling.sampling.cli", "attach", "12345", "--async-aware", "--async-mode", "all"]

Lib/test/test_profiling/test_sampling_profiler/test_collectors.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,10 @@ def test_flamegraph_collector_export(self):
539539

540540
# Export flamegraph
541541
with captured_stdout(), captured_stderr():
542-
collector.export(flamegraph_out.name)
542+
export_ok = collector.export(flamegraph_out.name)
543543

544544
# Verify file was created and contains valid data
545+
self.assertTrue(export_ok)
545546
self.assertTrue(os.path.exists(flamegraph_out.name))
546547
self.assertGreater(os.path.getsize(flamegraph_out.name), 0)
547548

@@ -560,6 +561,21 @@ def test_flamegraph_collector_export(self):
560561
self.assertIn('"value":', content)
561562
self.assertIn('"children":', content)
562563

564+
def test_flamegraph_collector_empty_export_fails(self):
565+
"""Test empty flamegraph export reports no output."""
566+
flamegraph_out = tempfile.NamedTemporaryFile(
567+
suffix=".html", delete=False
568+
)
569+
self.addCleanup(close_and_unlink, flamegraph_out)
570+
571+
collector = FlamegraphCollector(1000)
572+
573+
with captured_stdout(), captured_stderr():
574+
export_ok = collector.export(flamegraph_out.name)
575+
576+
self.assertFalse(export_ok)
577+
self.assertEqual(os.path.getsize(flamegraph_out.name), 0)
578+
563579
def test_gecko_collector_basic(self):
564580
"""Test basic GeckoCollector functionality."""
565581
collector = GeckoCollector(1000)
@@ -1666,8 +1682,9 @@ def test_diff_flamegraph_export(self):
16661682
self.addCleanup(close_and_unlink, flamegraph_out)
16671683

16681684
with captured_stdout(), captured_stderr():
1669-
diff.export(flamegraph_out.name)
1685+
export_ok = diff.export(flamegraph_out.name)
16701686

1687+
self.assertTrue(export_ok)
16711688
self.assertTrue(os.path.exists(flamegraph_out.name))
16721689
self.assertGreater(os.path.getsize(flamegraph_out.name), 0)
16731690

0 commit comments

Comments
 (0)