• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / relman-auto-nag / #4814

06 Nov 2023 08:11PM CUT coverage: 21.989% (-0.02%) from 22.006%
#4814

push

coveralls-python

suhaibmujahid
Exclude patches dated before the first crash from being a potential regressor

716 of 3586 branches covered (0.0%)

0 of 3 new or added lines in 1 file covered. (0.0%)

213 existing lines in 1 file now uncovered.

1924 of 8750 relevant lines covered (21.99%)

0.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/bugbot/crash/analyzer.py
1
# This Source Code Form is subject to the terms of the Mozilla Public
2
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
3
# You can obtain one at http://mozilla.org/MPL/2.0/.
4

5
import itertools
×
6
import re
×
7
from collections import defaultdict
×
8
from datetime import date, datetime, timedelta
×
9
from functools import cached_property
×
10
from typing import Iterable, Iterator
×
11

12
from dateutil import parser
×
13
from libmozdata import bugzilla, clouseau, connection, socorro
×
14
from libmozdata import utils as lmdutils
×
15
from libmozdata.bugzilla import Bugzilla
×
UNCOV
16
from libmozdata.connection import Connection
×
17

18
from bugbot import logger, utils
×
19
from bugbot.bug.analyzer import BugAnalyzer, BugsStore
×
20
from bugbot.components import ComponentName
×
UNCOV
21
from bugbot.crash import socorro_util
×
22

23
# The max offset from a memory address to be considered "near".
24
OFFSET_64_BIT = 0x1000
×
UNCOV
25
OFFSET_32_BIT = 0x100
×
26
# Allocator poison value addresses.
UNCOV
27
ALLOCATOR_ADDRESSES_64_BIT = (
×
28
    (0xE5E5E5E5E5E5E5E5, OFFSET_64_BIT),
29
    # On 64-bit windows, sometimes it could be doing something with a 32-bit
30
    # value gotten from freed memory, so it'll be 0X00000000E5E5E5E5 +/-, and
31
    # because of the address limitation, quite often it will be
32
    # 0X0000E5E5E5E5E5E5 +/-.
33
    (0x00000000E5E5E5E5, OFFSET_32_BIT),
34
    (0x0000E5E5E5E5E5E5, OFFSET_64_BIT),
35
    (0x4B4B4B4B4B4B4B4B, OFFSET_64_BIT),
36
)
UNCOV
37
ALLOCATOR_ADDRESSES_32_BIT = (
×
38
    (0xE5E5E5E5, OFFSET_32_BIT),
39
    (0x4B4B4B4B, OFFSET_32_BIT),
40
)
41
# Ranges where addresses are considered near allocator poison values.
UNCOV
42
ALLOCATOR_RANGES_64_BIT = tuple(
×
43
    (addr - offset, addr + offset) for addr, offset in ALLOCATOR_ADDRESSES_64_BIT
44
)
UNCOV
45
ALLOCATOR_RANGES_32_BIT = tuple(
×
46
    (addr - offset, addr + offset) for addr, offset in ALLOCATOR_ADDRESSES_32_BIT
47
)
48

49

UNCOV
50
def is_near_null_address(str_address) -> bool:
×
51
    """Check if the address is near null.
52

53
    Args:
54
        str_address: The memory address to check.
55

56
    Returns:
57
        True if the address is near null, False otherwise.
58
    """
59
    address = int(str_address, 0)
×
UNCOV
60
    is_64_bit = len(str_address) >= 18
×
61

62
    if is_64_bit:
×
UNCOV
63
        return -OFFSET_64_BIT <= address <= OFFSET_64_BIT
×
64

UNCOV
65
    return -OFFSET_32_BIT <= address <= OFFSET_32_BIT
×
66

67

UNCOV
68
def is_near_allocator_address(str_address) -> bool:
×
69
    """Check if the address is near an allocator poison value.
70

71
    Args:
72
        str_address: The memory address to check.
73

74
    Returns:
75
        True if the address is near an allocator poison value, False otherwise.
76
    """
77
    address = int(str_address, 0)
×
UNCOV
78
    is_64_bit = len(str_address) >= 18
×
79

UNCOV
80
    return any(
×
81
        low <= address <= high
82
        for low, high in (
83
            ALLOCATOR_RANGES_64_BIT if is_64_bit else ALLOCATOR_RANGES_32_BIT
84
        )
85
    )
86

87

88
# TODO: Move this to libmozdata
UNCOV
89
def generate_signature_page_url(params: dict, tab: str) -> str:
×
90
    """Generate a URL to the signature page on Socorro
91

92
    Args:
93
        params: the parameters for the search query.
94
        tab: the page tab that should be selected.
95

96
    Returns:
97
        The URL of the signature page on Socorro
98
    """
99
    web_url = socorro.Socorro.CRASH_STATS_URL
×
100
    query = lmdutils.get_params_for_url(params)
×
UNCOV
101
    return f"{web_url}/signature/{query}#{tab}"
×
102

103

UNCOV
104
class NoCrashReportFoundError(Exception):
×
105
    """There are no crash reports that meet the required criteria."""
106

107

UNCOV
108
class ClouseauDataAnalyzer:
×
109
    """Analyze the data returned by Crash Clouseau about a specific crash
110
    signature.
111
    """
112

113
    MINIMUM_CLOUSEAU_SCORE_THRESHOLD: int = 8
×
UNCOV
114
    DEFAULT_CRASH_COMPONENT = ComponentName("Core", "General")
×
115

NEW
116
    def __init__(
×
117
        self, reports: Iterable[dict], bugs_store: BugsStore, first_crash_date: datetime
118
    ):
UNCOV
119
        self._clouseau_reports = reports
×
NEW
120
        self._first_crash_date = first_crash_date
×
121
        self.bugs_store = bugs_store
×
122

123
    @cached_property
×
UNCOV
124
    def max_clouseau_score(self):
×
125
        """The maximum Clouseau score in the crash reports."""
126
        if not self._clouseau_reports:
×
127
            return 0
×
UNCOV
128
        return max(report["max_score"] for report in self._clouseau_reports)
×
129

130
    @cached_property
×
UNCOV
131
    def regressed_by_potential_bug_ids(self) -> set[int]:
×
132
        """The IDs for the bugs that their patches could have caused the crash."""
UNCOV
133
        return {
×
134
            changeset["bug_id"] for changeset in self.regressed_by_potential_patches
135
        }
136

UNCOV
137
    @cached_property
×
UNCOV
138
    def regressed_by_patch(self) -> str | None:
×
139
        """The hash of the patch that could have caused the crash."""
UNCOV
140
        potential_patches = {
×
141
            changeset["changeset"] for changeset in self.regressed_by_potential_patches
142
        }
UNCOV
143
        if len(potential_patches) == 1:
×
UNCOV
144
            return next(iter(potential_patches))
×
145
        return None
×
146

UNCOV
147
    @cached_property
×
148
    def regressed_by(self) -> int | None:
×
149
        """The ID of the bug that one of its patches could have caused
150
        the crash.
151

152
        If there are multiple bugs, the value will be `None`.
153
        """
UNCOV
154
        bug_ids = self.regressed_by_potential_bug_ids
×
UNCOV
155
        if len(bug_ids) == 1:
×
UNCOV
156
            return next(iter(bug_ids))
×
UNCOV
157
        return None
×
158

UNCOV
159
    @cached_property
×
160
    def regressed_by_potential_bugs(self) -> list[BugAnalyzer]:
×
161
        """The bugs whose patches could have caused the crash."""
162
        self.bugs_store.fetch_bugs(
×
163
            self.regressed_by_potential_bug_ids,
164
            [
165
                "id",
166
                "groups",
167
                "assigned_to",
168
                "product",
169
                "component",
170
                "_custom",
171
            ],
172
        )
173
        return [
×
174
            self.bugs_store.get_bug_by_id(bug_id)
175
            for bug_id in self.regressed_by_potential_bug_ids
176
        ]
177

UNCOV
178
    @cached_property
×
179
    def regressed_by_author(self) -> dict | None:
×
180
        """The author of the patch that could have caused the crash.
181

182
        If there are multiple regressors, the value will be `None`.
183

184
        The regressor bug assignee is considered as the author, even if the
185
        assignee is not the patch author.
186
        """
187

UNCOV
188
        if not self.regressed_by:
×
UNCOV
189
            return None
×
190

UNCOV
191
        bug = self.regressed_by_potential_bugs[0]
×
UNCOV
192
        assert bug.id == self.regressed_by
×
UNCOV
193
        return bug.get_field("assigned_to_detail")
×
194

195
    @cached_property
×
196
    def crash_component(self) -> ComponentName:
×
197
        """The component that the crash belongs to.
198

199
        If there are multiple components, the value will be the default one.
200
        """
UNCOV
201
        potential_components = {
×
202
            bug.component for bug in self.regressed_by_potential_bugs
203
        }
UNCOV
204
        if len(potential_components) == 1:
×
205
            return next(iter(potential_components))
×
206
        return self.DEFAULT_CRASH_COMPONENT
×
207

208
    @property
×
209
    def regressed_by_potential_patches(self) -> Iterator[dict]:
×
210
        """The patches that could have caused the crash.
211

212
        Example of a patch data:
213
            {
214
                "bug_id": 1668136,
215
                "changeset": "aa66fda02aac",
216
                "channel": "nightly",
217
                "is_backedout": False,
218
                "is_merge": False,
219
                "max_score": 0,
220
                "push_date": "Tue, 31 Oct 2023 09:30:58 GMT",
221
            }
222
        """
223
        minimum_accepted_score = max(
×
224
            self.MINIMUM_CLOUSEAU_SCORE_THRESHOLD, self.max_clouseau_score
225
        )
226
        return (
×
227
            changeset
228
            for report in self._clouseau_reports
229
            if report["max_score"] >= minimum_accepted_score
230
            for changeset in report["changesets"]
231
            if changeset["max_score"] >= minimum_accepted_score
232
            and not changeset["is_merge"]
233
            and not changeset["is_backedout"]
234
            and self._first_crash_date > parser.parse(changeset["push_date"])
235
        )
236

237

UNCOV
238
class SocorroDataAnalyzer(socorro_util.SignatureStats):
×
239
    """Analyze the data returned by Socorro."""
240

UNCOV
241
    _bugzilla_os_legal_values = None
×
UNCOV
242
    _bugzilla_cpu_legal_values_map = None
×
UNCOV
243
    _platforms = [
×
244
        {"short_name": "win", "name": "Windows"},
245
        {"short_name": "mac", "name": "Mac OS X"},
246
        {"short_name": "lin", "name": "Linux"},
247
        {"short_name": "and", "name": "Android"},
248
        {"short_name": "unknown", "name": "Unknown"},
249
    ]
250

UNCOV
251
    def __init__(
×
252
        self,
253
        signature: dict,
254
        num_total_crashes: int,
255
    ):
UNCOV
256
        super().__init__(signature, num_total_crashes, platforms=self._platforms)
×
257

UNCOV
258
    @classmethod
×
259
    def to_bugzilla_op_sys(cls, op_sys: str) -> str:
×
260
        """Return the corresponding OS name in Bugzilla for the provided OS name
261
        from Socorro.
262

263
        If the OS name is not recognized, return "Other".
264
        """
265
        if cls._bugzilla_os_legal_values is None:
×
266
            cls._bugzilla_os_legal_values = set(
×
267
                bugzilla.BugFields.fetch_field_values("op_sys")
268
            )
269

UNCOV
270
        if op_sys in cls._bugzilla_os_legal_values:
×
271
            return op_sys
×
272

273
        if op_sys.startswith("OS X ") or op_sys.startswith("macOS "):
×
274
            op_sys = "macOS"
×
UNCOV
275
        elif op_sys.startswith("Windows"):
×
UNCOV
276
            op_sys = "Windows"
×
UNCOV
277
        elif "Linux" in op_sys or op_sys.startswith("Ubuntu"):
×
UNCOV
278
            op_sys = "Linux"
×
279
        else:
UNCOV
280
            op_sys = "Other"
×
281

282
        return op_sys
×
283

UNCOV
284
    @cached_property
×
UNCOV
285
    def first_crash_date(self) -> datetime:
×
286
        """The date of the first crash within the query time range."""
UNCOV
287
        return parser.parse(self.signature["facets"]["histogram_date"][0]["term"])
×
288

UNCOV
289
    @property
×
UNCOV
290
    def first_crash_date_ymd(self) -> str:
×
291
        """The date of the first crash within the query time range.
292

293
        The date is in YYYY-MM-DD format.
294
        """
UNCOV
295
        return self.first_crash_date.strftime("%Y-%m-%d")
×
296

UNCOV
297
    @property
×
298
    def bugzilla_op_sys(self) -> str:
×
299
        """The name of the OS where the crash happens.
300

301
        The value is one of the legal values for Bugzilla's `op_sys` field.
302

303
        - If no OS name is found, the value will be "Unspecified".
304
        - If the OS name is not recognized, the value will be "Other".
305
        - If multiple OS names are found, the value will be "All". Unless the OS
306
          names can be resolved to a common name without a version. For example,
307
          "Windows 10" and "Windows 7" will become "Windows".
308
        """
309
        all_op_sys = {
×
310
            self.to_bugzilla_op_sys(op_sys["term"])
311
            for op_sys in self.signature["facets"]["platform_pretty_version"]
312
        }
313

314
        if len(all_op_sys) > 1:
×
315
            # Resolve to root OS name by removing the version number.
UNCOV
316
            all_op_sys = {op_sys.split(" ")[0] for op_sys in all_op_sys}
×
317

UNCOV
318
        if len(all_op_sys) == 2 and "Other" in all_op_sys:
×
319
            # TODO: explain this workaround.
UNCOV
320
            all_op_sys.remove("Other")
×
321

322
        if len(all_op_sys) == 1:
×
UNCOV
323
            return next(iter(all_op_sys))
×
324

UNCOV
325
        if len(all_op_sys) == 0:
×
UNCOV
326
            return "Unspecified"
×
327

UNCOV
328
        return "All"
×
329

330
    @classmethod
×
UNCOV
331
    def to_bugzilla_cpu(cls, cpu: str) -> str:
×
332
        """Return the corresponding CPU name in Bugzilla for the provided name
333
        from Socorro.
334

335
        If the CPU is not recognized, return "Other".
336
        """
UNCOV
337
        if cls._bugzilla_cpu_legal_values_map is None:
×
UNCOV
338
            cls._bugzilla_cpu_legal_values_map = {
×
339
                value.lower(): value
340
                for value in bugzilla.BugFields.fetch_field_values("rep_platform")
341
            }
342

UNCOV
343
        return cls._bugzilla_cpu_legal_values_map.get(cpu, "Other")
×
344

345
    @property
×
UNCOV
346
    def bugzilla_cpu_arch(self) -> str:
×
347
        """The CPU architecture of the devices where the crash happens.
348

349
        The value is one of the legal values for Bugzilla's `rep_platform` field.
350

351
        - If no CPU architecture is found, the value will be "Unspecified".
352
        - If the CPU architecture is not recognized, the value will be "Other".
353
        - If multiple CPU architectures are found, the value will "All".
354
        """
355
        all_cpu_arch = {
×
356
            self.to_bugzilla_cpu(cpu["term"])
357
            for cpu in self.signature["facets"]["cpu_arch"]
358
        }
359

360
        if len(all_cpu_arch) == 2 and "Other" in all_cpu_arch:
×
361
            all_cpu_arch.remove("Other")
×
362

UNCOV
363
        if len(all_cpu_arch) == 1:
×
UNCOV
364
            return next(iter(all_cpu_arch))
×
365

UNCOV
366
        if len(all_cpu_arch) == 0:
×
367
            return "Unspecified"
×
368

UNCOV
369
        return "All"
×
370

371
    @property
×
UNCOV
372
    def user_comments_page_url(self) -> str:
×
373
        """The URL to the Signature page on Socorro where the Comments tab is
374
        selected.
375
        """
376
        start_date = date.today() - timedelta(weeks=26)
×
UNCOV
377
        params = {
×
378
            "signature": self.signature_term,
379
            "date": socorro.SuperSearch.get_search_date(start_date),
380
        }
381
        return generate_signature_page_url(params, "comments")
×
382

383
    @property
×
384
    def num_user_comments(self) -> int:
×
385
        """The number of crash reports with user comments."""
386
        # TODO: count useful/interesting user comments (e.g., exclude one word comments)
UNCOV
387
        return self.signature["facets"]["cardinality_user_comments"]["value"]
×
388

UNCOV
389
    @property
×
390
    def has_user_comments(self) -> bool:
×
391
        """Whether the crash signature has any reports with a user comment."""
UNCOV
392
        return self.num_user_comments > 0
×
393

394
    @property
×
UNCOV
395
    def top_proto_signature(self) -> str:
×
396
        """The proto signature that occurs the most."""
UNCOV
397
        return self.signature["facets"]["proto_signature"][0]["term"]
×
398

399
    @property
×
UNCOV
400
    def num_top_proto_signature_crashes(self) -> int:
×
401
        """The number of crashes for the most occurring proto signature."""
UNCOV
402
        return self.signature["facets"]["proto_signature"][0]["count"]
×
403

UNCOV
404
    def _build_ids(self) -> Iterator[int]:
×
405
        """Yields the build IDs where the crash occurred."""
UNCOV
406
        for build_id in self.signature["facets"]["build_id"]:
×
407
            yield build_id["term"]
×
408

UNCOV
409
    @property
×
410
    def top_build_id(self) -> int:
×
411
        """The build ID where most crashes occurred."""
412
        return self.signature["facets"]["build_id"][0]["term"]
×
413

UNCOV
414
    @cached_property
×
UNCOV
415
    def num_near_null_crashes(self) -> int:
×
416
        """The number of crashes that occurred on addresses near null."""
UNCOV
417
        return sum(
×
418
            address["count"]
419
            for address in self.signature["facets"]["address"]
420
            if is_near_null_address(address["term"])
421
        )
422

UNCOV
423
    @property
×
UNCOV
424
    def is_near_null_crash(self) -> bool:
×
425
        """Whether all crashes occurred on addresses near null."""
UNCOV
426
        return self.num_near_null_crashes == self.num_crashes
×
427

428
    @property
×
UNCOV
429
    def is_potential_near_null_crash(self) -> bool:
×
430
        """Whether the signature is a potential near null crash.
431

432
        The value will be True if some but not all crashes occurred on addresses
433
        near null.
434
        """
435
        return not self.is_near_null_crash and self.num_near_null_crashes > 0
×
436

UNCOV
437
    @property
×
UNCOV
438
    def is_near_null_related_crash(self) -> bool:
×
439
        """Whether the signature is related to near null crashes.
440

441
        The value will be True if any of the crashes occurred on addresses near
442
        null.
443
        """
UNCOV
444
        return self.is_near_null_crash or self.is_potential_near_null_crash
×
445

446
    @cached_property
×
UNCOV
447
    def num_near_allocator_crashes(self) -> int:
×
448
        """The number of crashes that occurred on addresses near an allocator
449
        poison value.
450
        """
UNCOV
451
        return sum(
×
452
            address["count"]
453
            for address in self.signature["facets"]["address"]
454
            if is_near_allocator_address(address["term"])
455
        )
456

UNCOV
457
    @property
×
458
    def is_near_allocator_crash(self) -> bool:
×
459
        """Whether all crashes occurred on addresses near an allocator poison
460
        value.
461
        """
UNCOV
462
        return self.num_near_allocator_crashes == self.num_crashes
×
463

UNCOV
464
    @property
×
UNCOV
465
    def is_potential_near_allocator_crash(self) -> bool:
×
466
        """Whether the signature is a potential near allocator poison value
467
        crash.
468

469
        The value will be True if some but not all crashes occurred on addresses
470
        near an allocator poison value.
471
        """
UNCOV
472
        return not self.is_near_allocator_crash and self.num_near_allocator_crashes > 0
×
473

UNCOV
474
    @property
×
475
    def is_near_allocator_related_crash(self) -> bool:
×
476
        """Whether the signature is related to near allocator poison value
477
        crashes.
478

479
        The value will be True if any of the crashes occurred on addresses near
480
        an allocator poison value.
481
        """
482
        return self.is_near_allocator_crash or self.is_potential_near_allocator_crash
×
483

484

485
class SignatureAnalyzer(SocorroDataAnalyzer, ClouseauDataAnalyzer):
×
486
    """Analyze the data related to a signature.
487

488
    This includes data from Socorro and Clouseau.
489
    """
490

491
    def __init__(
×
492
        self,
493
        socorro_signature: dict,
494
        num_total_crashes: int,
495
        clouseau_reports: list[dict],
496
        bugs_store: BugsStore,
497
    ):
UNCOV
498
        SocorroDataAnalyzer.__init__(self, socorro_signature, num_total_crashes)
×
NEW
499
        ClouseauDataAnalyzer.__init__(
×
500
            self, clouseau_reports, bugs_store, self.first_crash_date
501
        )
502

503
    def _fetch_crash_reports(
×
504
        self,
505
        proto_signature: str,
506
        build_id: int | Iterable[int],
507
        limit: int = 1,
508
    ) -> Iterator[dict]:
UNCOV
509
        params = {
×
510
            "proto_signature": "=" + proto_signature,
511
            "build_id": build_id,
512
            "_columns": [
513
                "uuid",
514
            ],
515
            "_results_number": limit,
516
        }
517

UNCOV
518
        def handler(res: dict, data: dict):
×
UNCOV
519
            data.update(res)
×
520

UNCOV
521
        data: dict = {}
×
UNCOV
522
        socorro.SuperSearch(params=params, handler=handler, handlerdata=data).wait()
×
523

UNCOV
524
        yield from data["hits"]
×
525

UNCOV
526
    def _is_corrupted_crash_stack(self, processed_crash: dict) -> bool:
×
527
        """Whether the crash stack is corrupted.
528

529
        Args:
530
            processed_crash: The processed crash to check.
531

532
        Returns:
533
            True if the crash stack is corrupted, False otherwise.
534
        """
535

UNCOV
536
        return any(
×
537
            not frame["module"]
538
            for frame in processed_crash["json_dump"]["crashing_thread"]["frames"]
539
        )
540

UNCOV
541
    def fetch_representative_processed_crash(self) -> dict:
×
542
        """Fetch a processed crash to represent the signature.
543

544
        This could fetch multiple processed crashes and return the one that is
545
        most likely to be useful.
546
        """
UNCOV
547
        limit_to_top_proto_signature = (
×
548
            self.num_top_proto_signature_crashes / self.num_crashes > 0.6
549
        )
550

551
        candidate_reports = itertools.chain(
×
552
            # Reports with a higher score from clouseau are more likely to be
553
            # useful.
554
            sorted(
555
                self._clouseau_reports,
556
                key=lambda report: report["max_score"],
557
                reverse=True,
558
            ),
559
            # Next we try find reports from the top crashing build because they
560
            # are likely to be representative.
561
            self._fetch_crash_reports(self.top_proto_signature, self.top_build_id),
562
            self._fetch_crash_reports(self.top_proto_signature, self._build_ids()),
563
        )
564

565
        first_representative_report = None
×
UNCOV
566
        for i, report in enumerate(candidate_reports):
×
567
            uuid = report["uuid"]
×
UNCOV
568
            processed_crash = socorro.ProcessedCrash.get_processed(uuid)[uuid]
×
569
            if (
×
570
                limit_to_top_proto_signature
571
                and processed_crash["proto_signature"] != self.top_proto_signature
572
            ):
UNCOV
573
                continue
×
574

UNCOV
575
            if first_representative_report is None:
×
UNCOV
576
                first_representative_report = processed_crash
×
577

578
            if not self._is_corrupted_crash_stack(processed_crash):
×
579
                return processed_crash
×
580

UNCOV
581
            if i >= 20:
×
582
                # We have tried enough reports, give up.
UNCOV
583
                break
×
584

UNCOV
585
        if first_representative_report is not None:
×
586
            # Fall back to the first representative report that we found, even
587
            # if it's corrupted.
UNCOV
588
            return first_representative_report
×
589

590
        raise NoCrashReportFoundError(
×
591
            f"No crash report found with the most frequent proto signature for {self.signature_term}."
592
        )
593

UNCOV
594
    @cached_property
×
UNCOV
595
    def is_potential_security_crash(self) -> bool:
×
596
        """Whether the crash is related to a potential security bug.
597

598
        The value will be True if:
599
            - the signature is related to near allocator poison value crashes, or
600
            - one of the potential regressors is a security bug
601
        """
UNCOV
602
        return self.is_near_allocator_related_crash or any(
×
603
            bug.is_security for bug in self.regressed_by_potential_bugs
604
        )
605

606
    def has_moz_crash_reason(self, reason: str) -> bool:
×
607
        """Whether the crash has a specific MOZ_CRASH reason.
608

609
        Args:
610
            reason: The MOZ_CRASH reason to check.
611

612
        Returns:
613
            True if the any of the MOZ_CRASH reasons has a partial match with
614
            the provided reason.
615
        """
616
        return any(
×
617
            reason in moz_crash_reason["term"]
618
            for moz_crash_reason in self.signature["facets"]["moz_crash_reason"]
619
        )
620

UNCOV
621
    @property
×
UNCOV
622
    def process_type_summary(self) -> str:
×
623
        """The summary of the process types for the crash signature."""
UNCOV
624
        process_types = self.signature["facets"]["process_type"]
×
UNCOV
625
        if len(process_types) == 0:
×
626
            return "Unknown"
×
627

UNCOV
628
        if len(process_types) == 1:
×
UNCOV
629
            process_type = process_types[0]["term"]
×
630
            # Small process types are usually acronyms (e.g., gpu for GPU), thus
631
            # we use upper case for them. Otherwise, we capitalize the first letter.
UNCOV
632
            if len(process_type) <= 3:
×
UNCOV
633
                return process_type.upper()
×
UNCOV
634
            return process_type.capitalize()
×
635

636
        return "Multiple distinct types"
×
637

638

UNCOV
639
class SignaturesDataFetcher:
×
640
    """Fetch the data related to the given signatures."""
641

UNCOV
642
    MEMORY_ACCESS_ERROR_REASONS = (
×
643
        # On Windows:
644
        "EXCEPTION_ACCESS_VIOLATION_READ",
645
        "EXCEPTION_ACCESS_VIOLATION_WRITE",
646
        "EXCEPTION_ACCESS_VIOLATION_EXEC"
647
        # On Linux:
648
        "SIGSEGV / SEGV_MAPERR",
649
        "SIGSEGV / SEGV_ACCERR",
650
    )
651

UNCOV
652
    EXCLUDED_MOZ_REASON_STRINGS = (
×
653
        "MOZ_CRASH(OOM)",
654
        "MOZ_CRASH(Out of memory)",
655
        "out of memory",
656
        "Shutdown hanging",
657
        # TODO(investigate): do we need to exclude signatures that their reason
658
        # contains `[unhandlable oom]`?
659
        # Example: arena_t::InitChunk | arena_t::AllocRun | arena_t::MallocLarge | arena_t::Malloc | BaseAllocator::malloc | Allocator::malloc | PageMalloc
660
        # "[unhandlable oom]",
661
    )
662

663
    # If any of the crash reason starts with any of the following, then it is
664
    # Network or I/O error.
UNCOV
665
    EXCLUDED_IO_ERROR_REASON_PREFIXES = (
×
666
        "EXCEPTION_IN_PAGE_ERROR_READ",
667
        "EXCEPTION_IN_PAGE_ERROR_WRITE",
668
        "EXCEPTION_IN_PAGE_ERROR_EXEC",
669
    )
670

671
    # TODO(investigate): do we need to exclude all these signatures prefixes?
672
    EXCLUDED_SIGNATURE_PREFIXES = (
×
673
        "OOM | ",
674
        "bad hardware | ",
675
        "shutdownhang | ",
676
    )
677

UNCOV
678
    SUMMARY_DURATION = timedelta(weeks=10)
×
679

UNCOV
680
    def __init__(
×
681
        self,
682
        signatures: Iterable[str],
683
        product: str = "Firefox",
684
        channel: str = "nightly",
685
    ):
UNCOV
686
        self._signatures = set(signatures)
×
UNCOV
687
        self._product = product
×
UNCOV
688
        self._channel = channel
×
689

UNCOV
690
    @classmethod
×
UNCOV
691
    def find_new_actionable_crashes(
×
692
        cls,
693
        product: str,
694
        channel: str,
695
        days_to_check: int = 7,
696
        days_without_crashes: int = 7,
697
    ) -> "SignaturesDataFetcher":
698
        """Find new actionable crashes.
699

700
        Args:
701
            product: The product to check.
702
            channel: The release channel to check.
703
            days_to_check: The number of days to check for crashes.
704
            days_without_crashes: The number of days without crashes before the
705
                `days_to_check` to consider the signature new.
706

707
        Returns:
708
            A list of actionable signatures.
709
        """
UNCOV
710
        duration = days_to_check + days_without_crashes
×
UNCOV
711
        end_date = lmdutils.get_date_ymd("today")
×
UNCOV
712
        start_date = end_date - timedelta(duration)
×
UNCOV
713
        earliest_allowed_date = lmdutils.get_date_str(
×
714
            end_date - timedelta(days_to_check)
715
        )
UNCOV
716
        date_range = socorro.SuperSearch.get_search_date(start_date, end_date)
×
717

UNCOV
718
        params = {
×
719
            "product": product,
720
            "release_channel": channel,
721
            "date": date_range,
722
            # TODO(investigate): should we do a local filter instead of the
723
            # following (should we exclude the signature if one of the crashes
724
            # is a shutdown hang?):
725
            # If the `ipc_shutdown_state` or `shutdown_progress` field are
726
            # non-empty then it's a shutdown hang.
727
            "ipc_shutdown_state": "__null__",
728
            "shutdown_progress": "__null__",
729
            # TODO(investigate): should we use the following instead of the
730
            # local filter.
731
            # "oom_allocation_size": "!__null__",
732
            "_aggs.signature": [
733
                "moz_crash_reason",
734
                "reason",
735
                "possible_bit_flips_max_confidence",
736
                "_histogram.date",
737
                "_cardinality.install_time",
738
                "_cardinality.oom_allocation_size",
739
            ],
740
            "_results_number": 0,
741
            "_facets_size": 10000,
742
        }
743

744
        def handler(search_resp: dict, data: list):
×
745
            logger.debug(
×
746
                "Total of %d signatures received from Socorro",
747
                len(search_resp["facets"]["signature"]),
748
            )
749

750
            for crash in search_resp["facets"]["signature"]:
×
UNCOV
751
                signature = crash["term"]
×
752
                if any(
×
753
                    signature.startswith(excluded_prefix)
754
                    for excluded_prefix in cls.EXCLUDED_SIGNATURE_PREFIXES
755
                ):
756
                    # Ignore signatures that start with any of the excluded prefixes.
UNCOV
757
                    continue
×
758

UNCOV
759
                facets = crash["facets"]
×
760
                installations = facets["cardinality_install_time"]["value"]
×
UNCOV
761
                if installations <= 1:
×
762
                    # Ignore crashes that only happen on one installation.
UNCOV
763
                    continue
×
764

765
                first_date = facets["histogram_date"][0]["term"]
×
UNCOV
766
                if first_date < earliest_allowed_date:
×
767
                    # The crash is not new, skip it.
UNCOV
768
                    continue
×
769

UNCOV
770
                if any(
×
771
                    reason["term"].startswith(io_error_prefix)
772
                    for reason in facets["reason"]
773
                    for io_error_prefix in cls.EXCLUDED_IO_ERROR_REASON_PREFIXES
774
                ):
775
                    # Ignore Network or I/O error crashes.
776
                    continue
×
777

UNCOV
778
                if crash["count"] < 20:
×
779
                    # For signatures with low volume, having multiple types of
780
                    # memory errors indicates potential bad hardware crashes.
UNCOV
781
                    num_memory_error_types = sum(
×
782
                        reason["term"] in cls.MEMORY_ACCESS_ERROR_REASONS
783
                        for reason in facets["reason"]
784
                    )
785
                    if num_memory_error_types > 1:
×
786
                        # Potential bad hardware crash, skip it.
UNCOV
787
                        continue
×
788

789
                bit_flips_count = sum(
×
790
                    row["count"] for row in facets["possible_bit_flips_max_confidence"]
791
                )
UNCOV
792
                bit_flips_percentage = bit_flips_count / crash["count"]
×
UNCOV
793
                if bit_flips_percentage >= 0.2:
×
794
                    # Potential bad hardware crash, skip it.
UNCOV
795
                    continue
×
796

797
                # TODO(investigate): is this needed since we are already
798
                # filtering signatures that start with "OOM | "
799
                if facets["cardinality_oom_allocation_size"]["value"]:
×
800
                    # If one of the crashes is an OOM crash, skip it.
UNCOV
801
                    continue
×
802

803
                # TODO(investigate): do we need to check for the `moz_crash_reason`
UNCOV
804
                moz_crash_reasons = facets["moz_crash_reason"]
×
805
                if moz_crash_reasons and any(
×
806
                    excluded_reason in reason["term"]
807
                    for reason in moz_crash_reasons
808
                    for excluded_reason in cls.EXCLUDED_MOZ_REASON_STRINGS
809
                ):
810
                    continue
×
811

812
                data.append(signature)
×
813

814
        signatures: list = []
×
815
        socorro.SuperSearch(
×
816
            params=params,
817
            handler=handler,
818
            handlerdata=signatures,
819
        ).wait()
820

UNCOV
821
        logger.debug(
×
822
            "Total of %d signatures left after applying the filtering criteria",
823
            len(signatures),
824
        )
825

UNCOV
826
        return cls(signatures, product, channel)
×
827

828
    def fetch_clouseau_crash_reports(self) -> dict[str, list]:
×
829
        """Fetch the crash reports data from Crash Clouseau."""
UNCOV
830
        if not self._signatures:
×
UNCOV
831
            return {}
×
832

833
        logger.debug(
×
834
            "Fetch from Clouseau: requesting reports for %d signatures",
835
            len(self._signatures),
836
        )
837

838
        signature_reports = clouseau.Reports.get_by_signatures(
×
839
            self._signatures,
840
            product=self._product,
841
            channel=self._channel,
842
        )
843

844
        logger.debug(
×
845
            "Fetch from Clouseau: received reports for %d signatures",
846
            len(signature_reports),
847
        )
848

UNCOV
849
        return signature_reports
×
850

UNCOV
851
    def fetch_socorro_info(self) -> tuple[list[dict], int]:
×
852
        """Fetch the signature data from Socorro."""
UNCOV
853
        if not self._signatures:
×
UNCOV
854
            return [], 0
×
855

UNCOV
856
        end_date = lmdutils.get_date_ymd("today")
×
UNCOV
857
        start_date = end_date - self.SUMMARY_DURATION
×
UNCOV
858
        date_range = socorro.SuperSearch.get_search_date(start_date, end_date)
×
859

UNCOV
860
        params = {
×
861
            "product": self._product,
862
            # TODO(investigate): should we included all release channels?
863
            "release_channel": self._channel,
864
            # TODO(investigate): should we limit based on the build date as well?
865
            "date": date_range,
866
            # TODO: split signatures into chunks to avoid very long query URLs
867
            "signature": ["=" + signature for signature in self._signatures],
868
            "_aggs.signature": [
869
                "address",
870
                "build_id",
871
                "cpu_arch",
872
                "proto_signature",
873
                "_cardinality.user_comments",
874
                "cpu_arch",
875
                "platform_pretty_version",
876
                "_histogram.date",
877
                # The following are needed for SignatureStats:
878
                "platform",
879
                "is_garbage_collecting",
880
                "_cardinality.install_time",
881
                "startup_crash",
882
                "_histogram.uptime",
883
                "process_type",
884
                "moz_crash_reason",
885
            ],
886
            "_results_number": 0,
887
            "_facets_size": 10000,
888
        }
889

890
        def handler(search_results: dict, data: dict):
×
UNCOV
891
            data["num_total_crashes"] = search_results["total"]
×
UNCOV
892
            data["signatures"] = search_results["facets"]["signature"]
×
893

UNCOV
894
        logger.debug(
×
895
            "Fetch from Socorro: requesting info for %d signatures",
896
            len(self._signatures),
897
        )
898

UNCOV
899
        data: dict = {}
×
UNCOV
900
        socorro.SuperSearchUnredacted(
×
901
            params=params,
902
            handler=handler,
903
            handlerdata=data,
904
        ).wait()
905

UNCOV
906
        logger.debug(
×
907
            "Fetch from Socorro: received info for %d signatures",
908
            len(data["signatures"]),
909
        )
910

911
        return data["signatures"], data["num_total_crashes"]
×
912

913
    def fetch_bugs(
×
914
        self, include_fields: list[str] | None = None
915
    ) -> dict[str, list[dict]]:
916
        """Fetch bugs that are filed against the given signatures."""
917
        if not self._signatures:
×
918
            return {}
×
919

920
        params_base: dict = {
×
921
            "include_fields": [
922
                "cf_crash_signature",
923
            ],
924
        }
925

UNCOV
926
        if include_fields:
×
927
            params_base["include_fields"].extend(include_fields)
×
928

929
        params_list = []
×
930
        for signatures_chunk in Connection.chunks(list(self._signatures), 30):
×
931
            params = params_base.copy()
×
932
            n = int(utils.get_last_field_num(params))
×
933
            params[f"f{n}"] = "OP"
×
UNCOV
934
            params[f"j{n}"] = "OR"
×
935
            for signature in signatures_chunk:
×
UNCOV
936
                n += 1
×
UNCOV
937
                params[f"f{n}"] = "cf_crash_signature"
×
UNCOV
938
                params[f"o{n}"] = "regexp"
×
939
                params[f"v{n}"] = rf"\[(@ |@){re.escape(signature)}( \]|\])"
×
940
            params[f"f{n+1}"] = "CP"
×
UNCOV
941
            params_list.append(params)
×
942

UNCOV
943
        signatures_bugs: dict = defaultdict(list)
×
944

UNCOV
945
        def handler(res, data):
×
UNCOV
946
            for bug in res["bugs"]:
×
UNCOV
947
                for signature in utils.get_signatures(bug["cf_crash_signature"]):
×
948
                    if signature in self._signatures:
×
UNCOV
949
                        data[signature].append(bug)
×
950

UNCOV
951
        logger.debug(
×
952
            "Fetch from Bugzilla: requesting bugs for %d signatures",
953
            len(self._signatures),
954
        )
UNCOV
955
        timeout = utils.get_config("common", "bz_query_timeout")
×
956
        Bugzilla(
×
957
            timeout=timeout,
958
            queries=[
959
                connection.Query(Bugzilla.API_URL, params, handler, signatures_bugs)
960
                for params in params_list
961
            ],
962
        ).wait()
963

UNCOV
964
        logger.debug(
×
965
            "Fetch from Bugzilla: received bugs for %d signatures", len(signatures_bugs)
966
        )
967

UNCOV
968
        return signatures_bugs
×
969

970
    def analyze(self) -> list[SignatureAnalyzer]:
×
971
        """Analyze the data related to the signatures."""
972
        bugs = self.fetch_bugs()
×
973
        # TODO(investigate): For now, we are ignoring signatures that have bugs
974
        # filed even if they are closed long time ago. We should investigate
975
        # whether we should include the ones with closed bugs. For example, if
976
        # the bug was closed as Fixed years ago.
UNCOV
977
        self._signatures.difference_update(bugs.keys())
×
978

UNCOV
979
        clouseau_reports = self.fetch_clouseau_crash_reports()
×
980
        # TODO(investigate): For now, we are ignoring signatures that are not
981
        # analyzed by clouseau. We should investigate why they are not analyzed
982
        # and whether we should include them.
UNCOV
983
        self._signatures.intersection_update(clouseau_reports.keys())
×
984

UNCOV
985
        signatures, num_total_crashes = self.fetch_socorro_info()
×
UNCOV
986
        bugs_store = BugsStore()
×
987

UNCOV
988
        return [
×
989
            SignatureAnalyzer(
990
                signature,
991
                num_total_crashes,
992
                clouseau_reports[signature["term"]],
993
                bugs_store,
994
            )
995
            for signature in signatures
996
        ]
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc