Skip to content
Open
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
25 changes: 25 additions & 0 deletions lib/src/tor_lifecycle_gate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2026 Foundation Devices Inc.
//
// SPDX-License-Identifier: MIT

import 'dart:async';

/// Serializes Tor lifecycle operations and invalidates stale completions.
class TorLifecycleGate {
Future<void> _tail = Future.value();
int _generation = 0;

int get generation => _generation;

bool owns(int generation) => generation == _generation;

void invalidate() {
_generation++;
}

Future<T> run<T>(Future<T> Function() operation) {
final result = _tail.then((_) => operation());
_tail = result.then<void>((_) {}, onError: (Object _, StackTrace __) {});
return result;
}
}
125 changes: 89 additions & 36 deletions lib/tor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:path_provider/path_provider.dart';

import 'src/rust/api/tor.dart' as rust;
import 'src/rust_lib_init.dart';
import 'src/tor_lifecycle_gate.dart';

export 'src/rust/api/tor.dart' show TorError;

Expand Down Expand Up @@ -51,7 +52,13 @@ class Tor {
bool get starting => _startInFlight != null || _bootstrapInFlight != null;

Future<void>? _startInFlight;
int? _startGeneration;
Future<void>? _bootstrapInFlight;
int? _bootstrapGeneration;
final TorLifecycleGate _lifecycle = TorLifecycleGate();

/// Changes whenever the published Tor route is invalidated.
int get routeGeneration => _lifecycle.generation;

/// Flag to indicate that traffic should flow through the proxy.
bool _enabled = false;
Expand Down Expand Up @@ -139,30 +146,37 @@ class Tor {
return _bootstrapped ? Future.value() : bootstrap();
}

final generation = _lifecycle.generation;
final inFlight = _startInFlight;
if (inFlight != null) return inFlight;
if (inFlight != null && _startGeneration == generation) return inFlight;

late final Future<void> start;
start = _startInternal().whenComplete(() {
start = _lifecycle.run(() => _startInternal(generation)).whenComplete(() {
if (identical(_startInFlight, start)) {
_startInFlight = null;
_startGeneration = null;
}
});
_startInFlight = start;
_startGeneration = generation;
return start;
}

Future<void> _startInternal() async {
Future<void> _startInternal(int generation) async {
if (!_lifecycle.owns(generation)) return;

broadcastState();

await ensureRustLibInit();

// Set the state and cache directories.
final Directory appSupportDir = await getApplicationSupportDirectory();
final stateDir =
await Directory('${appSupportDir.path}/tor_state').create();
final cacheDir =
await Directory('${appSupportDir.path}/tor_cache').create();
final stateDir = await Directory(
'${appSupportDir.path}/tor_state',
).create();
final cacheDir = await Directory(
'${appSupportDir.path}/tor_cache',
).create();

try {
// Start Tor - this is a blocking operation
Expand All @@ -173,12 +187,27 @@ class Tor {
cacheDir: cacheDir.path,
);

_client = torInstance.client;
_proxy = torInstance.proxy;
_proxyPort = torInstance.socksPort;
// The getters above clone; free the container now instead of at GC so
// it cannot keep an extra client reference (and dir.lock) alive.
torInstance.dispose();
late final rust.TorClientWrapper client;
late final rust.TorProxyHandle proxy;
late final int proxyPort;
try {
client = torInstance.client;
proxy = torInstance.proxy;
proxyPort = torInstance.socksPort;
} finally {
// The getters above clone; free the container now instead of at GC so
// it cannot keep an extra client reference (and dir.lock) alive.
torInstance.dispose();
}

if (!_lifecycle.owns(generation)) {
await _stopResources(proxy, client);
return;
}

_client = client;
_proxy = proxy;
_proxyPort = proxyPort;
_started = true;
_bootstrapped = true; // startTor creates a bootstrapped client

Expand All @@ -202,31 +231,43 @@ class Tor {
///
/// Returns void.
Future<void> bootstrap() {
final generation = _lifecycle.generation;
final inFlight = _bootstrapInFlight;
if (inFlight != null) return inFlight;
if (inFlight != null && _bootstrapGeneration == generation) {
return inFlight;
}

late final Future<void> bootstrap;
bootstrap = _bootstrapInternal().whenComplete(() {
bootstrap =
_lifecycle.run(() => _bootstrapInternal(generation)).whenComplete(() {
if (identical(_bootstrapInFlight, bootstrap)) {
_bootstrapInFlight = null;
_bootstrapGeneration = null;
}
});
_bootstrapInFlight = bootstrap;
_bootstrapGeneration = generation;
return bootstrap;
}

Future<void> _bootstrapInternal() async {
if (_client == null) {
Future<void> _bootstrapInternal(int generation) async {
if (!_lifecycle.owns(generation)) return;

final client = _client;
if (client == null) {
throw ClientNotActive();
}

try {
await rust.bootstrap(client: _client!);
await rust.bootstrap(client: client);
if (!_lifecycle.owns(generation) || !identical(_client, client)) return;
_bootstrapped = true;
broadcastState();
} on rust.TorError catch (e) {
_bootstrapped = false;
broadcastState();
if (_lifecycle.owns(generation) && identical(_client, client)) {
_bootstrapped = false;
broadcastState();
}
throw CouldntBootstrapDirectory(rustError: e.toString());
}
}
Expand All @@ -238,10 +279,14 @@ class Tor {
}

/// Stops the proxy
Future<void> stop() async {
Future<void> stop() {
final proxy = _proxy;
final client = _client;

// Invalidate a start or bootstrap before it can publish stale state. The
// queued stop then waits for that operation to release its native handles.
_lifecycle.invalidate();

// Stop publishing the route before awaiting native shutdown so callers
// cannot start new work against a proxy that is being torn down.
_proxy = null;
Expand All @@ -251,6 +296,13 @@ class Tor {
_bootstrapped = false;
broadcastState();

return _lifecycle.run(() => _stopResources(proxy, client));
}

Future<void> _stopResources(
rust.TorProxyHandle? proxy,
rust.TorClientWrapper? client,
) async {
try {
if (proxy != null) {
await rust.stopProxy(proxy: proxy);
Expand All @@ -272,21 +324,22 @@ class Tor {

Future<void> isReady() async {
return await Future.doWhile(
() => Future.delayed(const Duration(seconds: 1)).then((_) {
// We are waiting and making absolutely no request unless:
// Tor is disabled
if (!enabled) {
return false;
}

// ...or Tor circuit is established
if (bootstrapped) {
return false;
}

// This way we avoid making clearnet req's while Tor is initialising
return true;
}));
() => Future.delayed(const Duration(seconds: 1)).then((_) {
// We are waiting and making absolutely no request unless:
// Tor is disabled
if (!enabled) {
return false;
}

// ...or Tor circuit is established
if (bootstrapped) {
return false;
}

// This way we avoid making clearnet req's while Tor is initialising
return true;
}),
);
}

void hello() {
Expand Down
61 changes: 61 additions & 0 deletions test/tor_lifecycle_gate_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// SPDX-FileCopyrightText: 2026 Foundation Devices Inc.
//
// SPDX-License-Identifier: MIT

import 'dart:async';

import 'package:flutter_test/flutter_test.dart';
import 'package:tor/src/tor_lifecycle_gate.dart';

void main() {
test('serializes lifecycle operations after failures', () async {
final gate = TorLifecycleGate();
final firstStarted = Completer<void>();
final releaseFirst = Completer<void>();
final order = <String>[];

final first = gate.run(() async {
order.add('start');
firstStarted.complete();
await releaseFirst.future;
throw StateError('failed start');
});
await firstStarted.future;

final second = gate.run(() async {
order.add('stop');
});
expect(order, ['start']);

releaseFirst.complete();
await expectLater(first, throwsStateError);
await second;
expect(order, ['start', 'stop']);
});

test('invalidation prevents an in-flight start from publishing', () async {
final gate = TorLifecycleGate();
final generation = gate.generation;
final startEntered = Completer<void>();
final releaseStart = Completer<void>();
var published = false;

final start = gate.run(() async {
startEntered.complete();
await releaseStart.future;
if (gate.owns(generation)) {
published = true;
}
});
await startEntered.future;

gate.invalidate();
expect(gate.generation, generation + 1);
final stop = gate.run(() async {});
releaseStart.complete();

await start;
await stop;
expect(published, isFalse);
});
}