Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Run a periodic crash-test #590

Merged
merged 2 commits into from
Mar 10, 2024
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
20 changes: 20 additions & 0 deletions .github/workflows/crash_test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Run against all markdown files in latest version of packages on pub.dev to
# see if any can provoke a crash

name: Crash Tests

on:
schedule:
# “At 00:00 (UTC) on Sunday.”
- cron: '0 0 * * 0'

jobs:
crash-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: dart-lang/setup-dart@fedb1266e91cf51be2fdb382869461a434b920a3
- name: Install dependencies
run: dart pub get
- name: Run crash_test.dart
run: dart test -P crash_test test/crash_test.dart
176 changes: 137 additions & 39 deletions test/crash_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import 'dart:convert';
import 'dart:io';
import 'dart:isolate';

import 'package:http/http.dart' as http;
import 'package:http/retry.dart' as http;
Expand All @@ -14,6 +15,18 @@ import 'package:test/test.dart';

// ignore_for_file: avoid_dynamic_calls

const extensions = [
'.md',
'.mkd',
'.mdwn',
'.mdown',
'.mdtxt',
'.mdtext',
'.markdown',
'README',
'CHANGELOG',
];

void main() async {
// This test is a really dumb and very slow crash-test.
// It downloads the latest package version for each package on pub.dev
Expand All @@ -26,6 +39,16 @@ void main() async {
test(
'crash test',
() async {
final started = DateTime.now();
var lastStatus = DateTime(0);
void status(String Function() message) {
if (DateTime.now().difference(lastStatus) >
const Duration(seconds: 30)) {
lastStatus = DateTime.now();
print(message());
}
}

final c = http.RetryClient(http.Client());
Future<dynamic> getJson(String url) async {
final u = Uri.tryParse(url);
Expand All @@ -50,79 +73,154 @@ void main() async {
((await getJson('https://pub.dev/api/package-names'))['packages']
as List)
.cast<String>();
print('Found ${packages.length} packages to scan');
//.take(3).toList(); // useful when testing
print('## Found ${packages.length} packages to scan');

final errors = <String>[];
final pool = Pool(50);
var count = 0;
var skipped = 0;
var lastStatus = DateTime.now();
final pool = Pool(50);
final packageVersions = <PackageVersion>[];
await Future.wait(packages.map((package) async {
await pool.withResource(() async {
final versionsResponse =
await getJson('https://pub.dev/api/packages/$package');
final archiveUrl = Uri.tryParse(
versionsResponse['latest']?['archive_url'] as String? ?? '',
final response = await getJson(
'https://pub.dev/api/packages/$package',
);
final entry = response['latest'] as Map?;
if (entry != null) {
packageVersions.add(PackageVersion(
package: package,
version: entry['version'] as String,
archiveUrl: entry['archive_url'] as String,
));
}
count++;
status(
() => 'Listed versions for $count / ${packages.length} packages',
);
});
}));

print('## Found ${packageVersions.length} package versions to scan');

count = 0;
final errors = <String>[];
var skipped = 0;
await Future.wait(packageVersions.map((pv) async {
await pool.withResource(() async {
final archiveUrl = Uri.tryParse(pv.archiveUrl);
if (archiveUrl == null) {
skipped++;
return;
}
late List<int> archive;
try {
archive = gzip.decode(await c.readBytes(archiveUrl));
archive = await c.readBytes(archiveUrl);
} on http.ClientException {
skipped++;
return;
} on IOException {
skipped++;
return;
}
try {
await TarReader.forEach(Stream.value(archive), (entry) async {
if (entry.name.endsWith('.md')) {
late String contents;
try {
final bytes = await http.ByteStream(entry.contents).toBytes();
contents = utf8.decode(bytes);
} on FormatException {
return; // ignore invalid utf8
}
try {
markdownToHtml(
contents,
extensionSet: ExtensionSet.gitHubWeb,
);
} catch (err, st) {
errors
.add('package:$package/${entry.name}, throws: $err\n$st');
}
}
});
} on FormatException {

final result = await _findMarkdownIssues(
pv.package,
pv.version,
archive,
);

// If tar decoding fails.
if (result == null) {
skipped++;
return;
}

errors.addAll(result);
result.forEach(print);
});
count++;
if (DateTime.now().difference(lastStatus) >
const Duration(seconds: 30)) {
lastStatus = DateTime.now();
print('Scanned $count / ${packages.length} (skipped $skipped),'
' found ${errors.length} issues');
}
status(() =>
'Scanned $count / ${packageVersions.length} (skipped $skipped),'
' found ${errors.length} issues');
}));

await pool.close();
c.close();

print('## Finished scanning');
print('Scanned ${packageVersions.length} package versions in '
'${DateTime.now().difference(started)}');

if (errors.isNotEmpty) {
print('Found issues:');
errors.forEach(print);
fail('Found ${errors.length} cases where markdownToHtml threw!');
}
},
timeout: const Timeout(Duration(hours: 1)),
timeout: const Timeout(Duration(hours: 5)),
jonasfj marked this conversation as resolved.
Show resolved Hide resolved
tags: 'crash_test', // skipped by default, see: dart_test.yaml
);
}

class PackageVersion {
final String package;
final String version;
final String archiveUrl;

PackageVersion({
required this.package,
required this.version,
required this.archiveUrl,
});
}

/// Scans [gzippedArchive] for markdown files and tries to parse them all.
///
/// Creates a list of issues that arose when parsing markdown files. The
/// [package] and [version] strings are used to construct nice issues.
/// An issue string may be multi-line, but should be printable.
///
/// Returns a list of issues, or `null` if decoding and parsing [gzippedArchive]
/// failed.
Future<List<String>?> _findMarkdownIssues(
jonasfj marked this conversation as resolved.
Show resolved Hide resolved
String package,
String version,
List<int> gzippedArchive,
) async {
return Isolate.run<List<String>?>(() async {
try {
final archive = gzip.decode(gzippedArchive);
final issues = <String>[];
await TarReader.forEach(Stream.value(archive), (entry) async {
if (extensions.any((ext) => entry.name.endsWith(ext))) {
late String contents;
try {
final bytes = await http.ByteStream(entry.contents).toBytes();
contents = utf8.decode(bytes);
} on FormatException {
return; // ignore invalid utf8
}
final start = DateTime.now();
try {
markdownToHtml(
contents,
extensionSet: ExtensionSet.gitHubWeb,
);
} catch (err, st) {
issues.add(
'package:$package-$version/${entry.name}, throws: $err\n$st');
}
final time = DateTime.now().difference(start);
if (time.inSeconds > 30) {
issues.add(
'package:$package-$version/${entry.name} took $time to process');
}
}
});
return issues;
} on FormatException {
return null;
}
}).timeout(const Duration(minutes: 2), onTimeout: () {
return ['package:$package-$version failed to be processed in 2 minutes'];
});
}
Loading