719 lines
19 KiB
Dart
719 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'dart:math';
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:enough_mail/enough_mail.dart' as mail;
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:sharedinbox/core/models/account.dart' as model;
|
|
import 'package:sharedinbox/core/storage/secure_storage.dart';
|
|
import 'package:sharedinbox/data/db/database.dart';
|
|
import 'package:sharedinbox/data/repositories/account_repository_impl.dart';
|
|
import 'package:sharedinbox/data/repositories/email_repository_impl.dart';
|
|
import 'package:sharedinbox/data/repositories/mailbox_repository_impl.dart';
|
|
|
|
Future<void> main() async {
|
|
final rawArgs = Platform.environment['SYNC_RELIABILITY_ARGS'];
|
|
final args = rawArgs == null || rawArgs.isEmpty
|
|
? const <String>[]
|
|
: const LineSplitter().convert(rawArgs);
|
|
await runSyncReliability(args);
|
|
}
|
|
|
|
Future<void> runSyncReliability(List<String> args) async {
|
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
|
|
|
final options = _parseOptions(args);
|
|
final random = Random();
|
|
|
|
stdout.writeln(
|
|
'sync-reliability: updates=${options.updates} cycles=${options.cycles} '
|
|
'imap-dbs=${options.imapDbs} jmap-dbs=${options.jmapDbs}',
|
|
);
|
|
|
|
final imapEnv = _StalwartEnv.fromEnvironment();
|
|
final jmapEnv = _StalwartEnv.fromEnvironment();
|
|
|
|
final protocolConfigs = <(_Protocol protocol, int dbCount)>[];
|
|
if (options.imapDbs > 0) {
|
|
protocolConfigs.add((_Protocol.imap, options.imapDbs));
|
|
}
|
|
if (options.jmapDbs > 0) {
|
|
protocolConfigs.add((_Protocol.jmap, options.jmapDbs));
|
|
}
|
|
|
|
if (protocolConfigs.isEmpty) {
|
|
throw StateError(
|
|
'No DBs configured. Set --imap-dbs and/or --jmap-dbs > 0.',
|
|
);
|
|
}
|
|
|
|
for (final config in protocolConfigs) {
|
|
final protocol = config.$1;
|
|
final dbCount = config.$2;
|
|
stdout.writeln('\n== protocol: ${protocol.name} dbs=$dbCount ==');
|
|
|
|
final tempRoot = await Directory.systemTemp.createTemp(
|
|
'sharedinbox_sync_reliability_',
|
|
);
|
|
final runners = <_Runner>[];
|
|
for (var i = 0; i < dbCount; i++) {
|
|
final runner = await _createRunner(
|
|
rootDir: Directory(p.join(tempRoot.path, 'db_${i + 1}')),
|
|
accountId: 'sync-account',
|
|
env: protocol == _Protocol.imap ? imapEnv : jmapEnv,
|
|
protocol: protocol,
|
|
);
|
|
runners.add(runner);
|
|
}
|
|
|
|
try {
|
|
for (var cycle = 1; cycle <= options.cycles; cycle++) {
|
|
stdout.writeln('cycle $cycle/${options.cycles}: sync start');
|
|
await _fullSyncAll(runners);
|
|
|
|
final opPlan = await _buildOperationPlan(
|
|
runners,
|
|
updates: options.updates,
|
|
random: random,
|
|
);
|
|
|
|
stdout.writeln(
|
|
'cycle $cycle/${options.cycles}: running ${opPlan.length} concurrent mutations',
|
|
);
|
|
|
|
await Future.wait(opPlan.map((op) => op.run()));
|
|
|
|
await _waitForConvergence(runners, cycle: cycle);
|
|
stdout.writeln('cycle $cycle/${options.cycles}: OK');
|
|
}
|
|
} finally {
|
|
for (final runner in runners) {
|
|
await runner.close();
|
|
}
|
|
await tempRoot.delete(recursive: true);
|
|
}
|
|
}
|
|
|
|
stdout.writeln('\nAll sync reliability checks passed.');
|
|
}
|
|
|
|
Future<void> _waitForConvergence(
|
|
List<_Runner> runners, {
|
|
required int cycle,
|
|
}) async {
|
|
const maxAttempts = 20;
|
|
const settleDelay = Duration(milliseconds: 250);
|
|
|
|
for (var attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
await _fullSyncAll(runners);
|
|
try {
|
|
await _assertSnapshotsEqual(runners, cycle: cycle);
|
|
if (attempt > 1) {
|
|
stdout.writeln('cycle $cycle: converged after $attempt sync attempts');
|
|
}
|
|
return;
|
|
} catch (_) {
|
|
if (attempt == maxAttempts) break;
|
|
await Future<void>.delayed(settleDelay);
|
|
}
|
|
}
|
|
|
|
stdout.writeln(
|
|
'cycle $cycle: convergence not reached, forcing full resync on all DBs',
|
|
);
|
|
await Future.wait(runners.map((r) => r.forceFullResync()));
|
|
|
|
await _assertSnapshotsEqual(runners, cycle: cycle);
|
|
}
|
|
|
|
Future<void> _fullSyncAll(List<_Runner> runners) async {
|
|
await Future.wait(runners.map((r) => r.syncAll()));
|
|
}
|
|
|
|
Future<List<_Op>> _buildOperationPlan(
|
|
List<_Runner> runners, {
|
|
required int updates,
|
|
required Random random,
|
|
}) async {
|
|
final allIdsSet = <String>{};
|
|
for (final runner in runners) {
|
|
allIdsSet.addAll(await runner.emailIds());
|
|
}
|
|
final allIds = allIdsSet.toList()..sort();
|
|
final deletableIds = <String>{...allIds};
|
|
|
|
final ops = <_Op>[];
|
|
|
|
for (var i = 0; i < updates; i++) {
|
|
final target = runners[random.nextInt(runners.length)];
|
|
final roll = random.nextInt(100);
|
|
|
|
if (roll < 40 || allIds.isEmpty) {
|
|
ops.add(
|
|
_Op(
|
|
label: 'create',
|
|
run: () async => target.createMessage(),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (roll < 70) {
|
|
final id = allIds[random.nextInt(allIds.length)];
|
|
final updateSeen = random.nextBool();
|
|
final flagValue = random.nextBool();
|
|
ops.add(
|
|
_Op(
|
|
label: 'update',
|
|
run: () async {
|
|
await target.setRandomFlag(
|
|
id,
|
|
useSeen: updateSeen,
|
|
value: flagValue,
|
|
);
|
|
},
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (deletableIds.isEmpty) {
|
|
ops.add(
|
|
_Op(
|
|
label: 'create',
|
|
run: () async => target.createMessage(),
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
final id = deletableIds.elementAt(random.nextInt(deletableIds.length));
|
|
deletableIds.remove(id);
|
|
ops.add(
|
|
_Op(
|
|
label: 'delete',
|
|
run: () async => target.deleteIfPresent(id),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ops;
|
|
}
|
|
|
|
Future<void> _assertSnapshotsEqual(
|
|
List<_Runner> runners, {
|
|
required int cycle,
|
|
}) async {
|
|
final baseline = runners.first;
|
|
final baselineSnapshot = await baseline.snapshot();
|
|
|
|
for (var i = 1; i < runners.length; i++) {
|
|
final snapshot = await runners[i].snapshot();
|
|
if (baselineSnapshot != snapshot) {
|
|
final leftLines = const LineSplitter().convert(baselineSnapshot);
|
|
final rightLines = const LineSplitter().convert(snapshot);
|
|
final max = leftLines.length > rightLines.length
|
|
? leftLines.length
|
|
: rightLines.length;
|
|
|
|
var diffLine = -1;
|
|
for (var line = 0; line < max; line++) {
|
|
final l = line < leftLines.length ? leftLines[line] : '<missing>';
|
|
final r = line < rightLines.length ? rightLines[line] : '<missing>';
|
|
if (l != r) {
|
|
diffLine = line + 1;
|
|
break;
|
|
}
|
|
}
|
|
|
|
throw StateError(
|
|
'DB snapshots differ after cycle $cycle. '
|
|
'First differing line: $diffLine between db1 and db${i + 1}\n'
|
|
'--- db1 ---\n$baselineSnapshot\n'
|
|
'--- db${i + 1} ---\n$snapshot',
|
|
);
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < runners.length; i++) {
|
|
final pending = await runners[i].pendingCount();
|
|
if (pending != 0) {
|
|
throw StateError(
|
|
'Pending queue not empty after cycle $cycle: db${i + 1}=$pending',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<_Runner> _createRunner({
|
|
required Directory rootDir,
|
|
required String accountId,
|
|
required _StalwartEnv env,
|
|
required _Protocol protocol,
|
|
}) async {
|
|
await rootDir.create(recursive: true);
|
|
final dbFile = File(p.join(rootDir.path, 'db.sqlite'));
|
|
final db = AppDatabase(NativeDatabase(dbFile));
|
|
final storage = _MemSecureStorage();
|
|
final accounts = AccountRepositoryImpl(db, storage);
|
|
|
|
final mailboxRepo = MailboxRepositoryImpl(
|
|
db,
|
|
accounts,
|
|
imapConnect: _connectImapPlaintext,
|
|
httpClient: http.Client(),
|
|
);
|
|
final emailRepo = EmailRepositoryImpl(
|
|
db,
|
|
accounts,
|
|
imapConnect: _connectImapPlaintext,
|
|
smtpConnect: _connectSmtpPlaintext,
|
|
httpClient: http.Client(),
|
|
);
|
|
|
|
final account = switch (protocol) {
|
|
_Protocol.imap => model.Account(
|
|
id: accountId,
|
|
displayName: 'Sync Reliability IMAP',
|
|
email: env.user,
|
|
imapHost: env.imapHost,
|
|
imapPort: env.imapPort,
|
|
smtpHost: env.smtpHost,
|
|
smtpPort: env.smtpPort,
|
|
),
|
|
_Protocol.jmap => model.Account(
|
|
id: accountId,
|
|
displayName: 'Sync Reliability JMAP',
|
|
email: env.user,
|
|
type: model.AccountType.jmap,
|
|
jmapUrl: '${env.baseUrl}/.well-known/jmap',
|
|
imapHost: env.imapHost,
|
|
imapPort: env.imapPort,
|
|
smtpHost: env.smtpHost,
|
|
smtpPort: env.smtpPort,
|
|
),
|
|
};
|
|
|
|
await accounts.addAccount(account, env.password);
|
|
|
|
return _Runner(
|
|
protocol: protocol,
|
|
accountId: accountId,
|
|
accountEmail: env.user,
|
|
imapHost: env.imapHost,
|
|
imapPort: env.imapPort,
|
|
accountPassword: env.password,
|
|
db: db,
|
|
mailboxes: mailboxRepo,
|
|
emails: emailRepo,
|
|
);
|
|
}
|
|
|
|
Future<mail.ImapClient> _connectImapPlaintext(
|
|
model.Account account,
|
|
String username,
|
|
String password,
|
|
) async {
|
|
final client = mail.ImapClient(
|
|
defaultResponseTimeout: const Duration(seconds: 20),
|
|
);
|
|
await client.connectToServer(
|
|
account.imapHost,
|
|
account.imapPort,
|
|
// ignore: avoid_redundant_argument_values
|
|
isSecure: false,
|
|
);
|
|
await client.login(username, password);
|
|
return client;
|
|
}
|
|
|
|
Future<mail.SmtpClient> _connectSmtpPlaintext(
|
|
model.Account account,
|
|
String username,
|
|
String password,
|
|
) async {
|
|
final at = account.email.lastIndexOf('@');
|
|
final domain = at == -1 ? account.smtpHost : account.email.substring(at + 1);
|
|
final client = mail.SmtpClient(domain);
|
|
await client.connectToServer(
|
|
account.smtpHost,
|
|
account.smtpPort,
|
|
// ignore: avoid_redundant_argument_values
|
|
isSecure: false,
|
|
);
|
|
await client.ehlo();
|
|
await client.authenticate(username, password);
|
|
return client;
|
|
}
|
|
|
|
class _Runner {
|
|
_Runner({
|
|
required this.protocol,
|
|
required this.accountId,
|
|
required this.accountEmail,
|
|
required this.imapHost,
|
|
required this.imapPort,
|
|
required this.accountPassword,
|
|
required this.db,
|
|
required this.mailboxes,
|
|
required this.emails,
|
|
});
|
|
|
|
final _Protocol protocol;
|
|
final String accountId;
|
|
final String accountEmail;
|
|
final String imapHost;
|
|
final int imapPort;
|
|
final String accountPassword;
|
|
final AppDatabase db;
|
|
final MailboxRepositoryImpl mailboxes;
|
|
final EmailRepositoryImpl emails;
|
|
|
|
int _createCounter = 0;
|
|
|
|
Future<void> syncAll() async {
|
|
await emails.flushPendingChanges(accountId, accountPassword);
|
|
await mailboxes.syncMailboxes(accountId);
|
|
final mailboxRows = await (db.select(db.mailboxes)
|
|
..where((t) => t.accountId.equals(accountId))
|
|
..orderBy([(t) => OrderingTerm.asc(t.path)]))
|
|
.get();
|
|
if (mailboxRows.isEmpty) {
|
|
throw StateError('No mailboxes found for account $accountId after sync');
|
|
}
|
|
|
|
for (final mailbox in mailboxRows) {
|
|
await emails.syncEmails(accountId, mailbox.path);
|
|
}
|
|
}
|
|
|
|
Future<void> createMessage() async {
|
|
_createCounter++;
|
|
final now = DateTime.now().microsecondsSinceEpoch;
|
|
final builder = mail.MessageBuilder()
|
|
..from = [mail.MailAddress('Sync Bot', accountEmail)]
|
|
..to = [mail.MailAddress('Sync Bot', accountEmail)]
|
|
..subject = 'sync-reliability-${protocol.name}-$now-$_createCounter'
|
|
..text = 'sync reliability body $now';
|
|
final client = mail.ImapClient(
|
|
defaultResponseTimeout: const Duration(seconds: 20),
|
|
);
|
|
await client.connectToServer(
|
|
imapHost,
|
|
imapPort,
|
|
// ignore: avoid_redundant_argument_values
|
|
isSecure: false,
|
|
);
|
|
try {
|
|
await client.login(accountEmail, accountPassword);
|
|
await client.appendMessage(
|
|
builder.buildMimeMessage(),
|
|
targetMailboxPath: 'INBOX',
|
|
);
|
|
} finally {
|
|
await client.logout();
|
|
}
|
|
}
|
|
|
|
Future<void> setRandomFlag(
|
|
String emailId, {
|
|
required bool useSeen,
|
|
required bool value,
|
|
}) async {
|
|
if (!await _emailExists(emailId)) {
|
|
return;
|
|
}
|
|
|
|
if (useSeen) {
|
|
await emails.setFlag(emailId, seen: value);
|
|
} else {
|
|
await emails.setFlag(emailId, flagged: value);
|
|
}
|
|
}
|
|
|
|
Future<void> deleteIfPresent(String emailId) async {
|
|
if (!await _emailExists(emailId)) {
|
|
return;
|
|
}
|
|
await emails.deleteEmail(emailId);
|
|
}
|
|
|
|
Future<bool> _emailExists(String emailId) async {
|
|
final row = await (db.select(db.emails)..where((t) => t.id.equals(emailId)))
|
|
.getSingleOrNull();
|
|
return row != null;
|
|
}
|
|
|
|
Future<List<String>> emailIds() async {
|
|
final rows = await (db.select(db.emails)
|
|
..where((t) => t.accountId.equals(accountId))
|
|
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
|
.get();
|
|
return rows.map((e) => e.id).toList(growable: false);
|
|
}
|
|
|
|
Future<int> pendingCount() async {
|
|
final rows = await (db.select(db.pendingChanges)
|
|
..where((t) => t.accountId.equals(accountId)))
|
|
.get();
|
|
return rows.length;
|
|
}
|
|
|
|
Future<String> snapshot() async {
|
|
final mailboxRows = await (db.select(db.mailboxes)
|
|
..where((t) => t.accountId.equals(accountId))
|
|
..orderBy([(t) => OrderingTerm.asc(t.path)]))
|
|
.get();
|
|
final emailRows = await (db.select(db.emails)
|
|
..where((t) => t.accountId.equals(accountId))
|
|
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
|
.get();
|
|
final pendingRows = await (db.select(db.pendingChanges)
|
|
..where((t) => t.accountId.equals(accountId))
|
|
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
|
.get();
|
|
|
|
final obj = {
|
|
'mailboxes': mailboxRows
|
|
.map(
|
|
(r) => {
|
|
'id': r.id,
|
|
'accountId': r.accountId,
|
|
'path': r.path,
|
|
'name': r.name,
|
|
'unreadCount': r.unreadCount,
|
|
'totalCount': r.totalCount,
|
|
'role': r.role,
|
|
},
|
|
)
|
|
.toList(growable: false),
|
|
'emails': emailRows
|
|
.map(
|
|
(r) => {
|
|
'id': r.id,
|
|
'accountId': r.accountId,
|
|
'mailboxPath': r.mailboxPath,
|
|
'uid': r.uid,
|
|
'subject': r.subject,
|
|
'fromJson': r.fromJson,
|
|
'toAddresses': r.toAddresses,
|
|
'ccJson': r.ccJson,
|
|
'preview': r.preview,
|
|
'isSeen': r.isSeen,
|
|
'isFlagged': r.isFlagged,
|
|
'hasAttachment': r.hasAttachment,
|
|
},
|
|
)
|
|
.toList(growable: false),
|
|
'pendingChanges': pendingRows
|
|
.map(
|
|
(r) => {
|
|
'accountId': r.accountId,
|
|
'resourceType': r.resourceType,
|
|
'resourceId': r.resourceId,
|
|
'changeType': r.changeType,
|
|
'payload': r.payload,
|
|
'attempts': r.attempts,
|
|
'lastError': r.lastError,
|
|
},
|
|
)
|
|
.toList(growable: false),
|
|
};
|
|
|
|
return const JsonEncoder.withIndent(' ').convert(obj);
|
|
}
|
|
|
|
Future<void> close() async {
|
|
await db.close();
|
|
}
|
|
|
|
Future<void> forceFullResync() async {
|
|
await (db.delete(db.syncStates)
|
|
..where((t) => t.accountId.equals(accountId)))
|
|
.go();
|
|
await (db.delete(db.emails)..where((t) => t.accountId.equals(accountId)))
|
|
.go();
|
|
await (db.delete(db.mailboxes)..where((t) => t.accountId.equals(accountId)))
|
|
.go();
|
|
await syncAll();
|
|
}
|
|
}
|
|
|
|
class _MemSecureStorage implements SecureStorage {
|
|
final Map<String, String> _values = <String, String>{};
|
|
|
|
@override
|
|
Future<String?> read({required String key}) async => _values[key];
|
|
|
|
@override
|
|
Future<void> write({required String key, required String? value}) async {
|
|
if (value == null) {
|
|
_values.remove(key);
|
|
} else {
|
|
_values[key] = value;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> delete({required String key}) async {
|
|
_values.remove(key);
|
|
}
|
|
}
|
|
|
|
class _Op {
|
|
_Op({required this.label, required this.run});
|
|
|
|
final String label;
|
|
final Future<void> Function() run;
|
|
}
|
|
|
|
class _StalwartEnv {
|
|
const _StalwartEnv({
|
|
required this.baseUrl,
|
|
required this.imapHost,
|
|
required this.imapPort,
|
|
required this.smtpHost,
|
|
required this.smtpPort,
|
|
required this.user,
|
|
required this.password,
|
|
});
|
|
|
|
final String baseUrl;
|
|
final String imapHost;
|
|
final int imapPort;
|
|
final String smtpHost;
|
|
final int smtpPort;
|
|
final String user;
|
|
final String password;
|
|
|
|
factory _StalwartEnv.fromEnvironment() {
|
|
final baseUrl =
|
|
Platform.environment['STALWART_URL'] ?? 'http://127.0.0.1:8080';
|
|
final imapHost = Platform.environment['STALWART_IMAP_HOST'] ?? '127.0.0.1';
|
|
final imapPort =
|
|
int.tryParse(Platform.environment['STALWART_IMAP_PORT'] ?? '') ?? 1430;
|
|
final smtpHost = Platform.environment['STALWART_SMTP_HOST'] ?? '127.0.0.1';
|
|
final smtpPort =
|
|
int.tryParse(Platform.environment['STALWART_SMTP_PORT'] ?? '') ?? 1025;
|
|
final user = Platform.environment['STALWART_USER_B'] ?? 'alice@example.com';
|
|
final password = Platform.environment['STALWART_PASS_B'] ?? 'secret';
|
|
|
|
return _StalwartEnv(
|
|
baseUrl: baseUrl,
|
|
imapHost: imapHost,
|
|
imapPort: imapPort,
|
|
smtpHost: smtpHost,
|
|
smtpPort: smtpPort,
|
|
user: user,
|
|
password: password,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Options {
|
|
const _Options({
|
|
required this.updates,
|
|
required this.cycles,
|
|
required this.imapDbs,
|
|
required this.jmapDbs,
|
|
});
|
|
|
|
final int updates;
|
|
final int cycles;
|
|
final int imapDbs;
|
|
final int jmapDbs;
|
|
}
|
|
|
|
enum _Protocol { imap, jmap }
|
|
|
|
_Options _parseOptions(List<String> args) {
|
|
var updates = 10;
|
|
var cycles = 3;
|
|
var imapDbs = 1;
|
|
var jmapDbs = 1;
|
|
|
|
final positionals = <String>[];
|
|
|
|
for (final arg in args) {
|
|
if (arg == '--help' || arg == '-h') {
|
|
_printUsageAndExit(0);
|
|
}
|
|
|
|
if (arg.startsWith('--updates=')) {
|
|
updates = _parsePositiveInt(arg.split('=').last, '--updates');
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--cycles=')) {
|
|
cycles = _parsePositiveInt(arg.split('=').last, '--cycles');
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--imap-dbs=')) {
|
|
imapDbs = _parseNonNegativeInt(arg.split('=').last, '--imap-dbs');
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('--jmap-dbs=')) {
|
|
jmapDbs = _parseNonNegativeInt(arg.split('=').last, '--jmap-dbs');
|
|
continue;
|
|
}
|
|
|
|
if (arg.startsWith('-')) {
|
|
throw StateError('Unknown option: $arg');
|
|
}
|
|
|
|
positionals.add(arg);
|
|
}
|
|
|
|
if (positionals.isNotEmpty) {
|
|
updates = _parsePositiveInt(positionals[0], 'updates');
|
|
}
|
|
if (positionals.length > 1) {
|
|
cycles = _parsePositiveInt(positionals[1], 'cycles');
|
|
}
|
|
if (positionals.length > 2) {
|
|
throw StateError('Too many positional args: $positionals');
|
|
}
|
|
|
|
if (imapDbs == 0 && jmapDbs == 0) {
|
|
throw StateError('At least one of --imap-dbs or --jmap-dbs must be > 0');
|
|
}
|
|
|
|
return _Options(
|
|
updates: updates,
|
|
cycles: cycles,
|
|
imapDbs: imapDbs,
|
|
jmapDbs: jmapDbs,
|
|
);
|
|
}
|
|
|
|
int _parsePositiveInt(String value, String name) {
|
|
final parsed = int.tryParse(value);
|
|
if (parsed == null || parsed <= 0) {
|
|
throw StateError('$name must be a positive integer, got "$value"');
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
int _parseNonNegativeInt(String value, String name) {
|
|
final parsed = int.tryParse(value);
|
|
if (parsed == null || parsed < 0) {
|
|
throw StateError('$name must be a non-negative integer, got "$value"');
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
Never _printUsageAndExit(int code) {
|
|
stdout.writeln(
|
|
'Usage: fvm flutter pub run scripts/sync_reliability.dart [updates] [cycles] '
|
|
'[--imap-dbs=N] [--jmap-dbs=N]\n\n'
|
|
'Defaults: updates=10 cycles=3 imap-dbs=1 jmap-dbs=1',
|
|
);
|
|
exit(code);
|
|
}
|