Skip to content

Bump Arti to 2.7.0, fix FFI handle lifetimes, and harden Tor lifecycle - #36

Open
sneurlax wants to merge 35 commits into
mainfrom
feat/bump-arti
Open

sneurlax wants to merge 35 commits into
mainfrom
feat/bump-arti

Conversation

@sneurlax

@sneurlax sneurlax commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

Bumps Arti to 2.7.0 (needs Rust 1.92) and fixes FFI handle lifetimes and the Tor start/stop lifecycle found in review.

Breaking changes

  • C callers: tor_start returns struct Tor { client, proxy, port }. The SOCKS listeners are bound but idle until tor_proxy_start(proxy) runs after bootstrap. Dart's Tor.start does this for you.
  • Tor.disable now stops the proxy and returns Future<void>, which can throw on release failure. Await it or attach an error handler.
  • Bootstrap has a two-minute default deadline. Before, Tor.start waited indefinitely. Pass a longer bootstrapTimeout on slow networks.
  • tor_client_bootstrap returns a TorBootstrapStatus code. C enumerators are prefixed TorBootstrapStatus_; numeric values and Dart names are unchanged.

Fixes

  • tor_client_bootstrap and tor_client_set_dormant no longer free the client handle Dart still holds. Adds tor_client_free, and Tor.stop releases it.
  • The SOCKS port is allocated atomically: Rust binds the IPv4 listener, so the probe-then-bind race is gone.
  • Tor.stop resets state, so Tor can restart. A stop during bootstrap cancels it promptly. Concurrent start calls share one attempt.
  • Bootstrap failure releases the native client and proxy. Cleanup errors surface through lastCleanupError.
  • Error strings are released after Dart reads them, and NUL bytes are stripped from them.
  • Null client handles return NullClient instead of aborting the process.
  • Directory strings passed to tor_start are freed.

arti 2.6 requires Rust 1.91.
@sneurlax

Copy link
Copy Markdown
Member Author

Supercedes #33 , which only bumped us to the point at which the upstream Foundation-Devices/tor had at the time this was created.

@sneurlax sneurlax closed this Sep 16, 2026
@sneurlax sneurlax reopened this Sep 16, 2026
@sneurlax sneurlax changed the title Bump Arti Bump Arti and handle FFI lifetime Oct 6, 2026
@sneurlax
sneurlax force-pushed the feat/bump-arti branch 2 times, most recently from 48acab2 to 5c3f182 Compare October 6, 2026 18:28
tor_client_bootstrap() rebuilt the owning Box from the raw pointer Dart
holds and let it drop on return, freeing the TorClient allocation while
Dart kept using the address. tor_client_set_dormant() did the same dance
but leaked the Box back, so it only worked as long as it was never
preceded by a bootstrap call. Any set_dormant() after start() was a use
after free; a bootstrap failure followed by stop() left a dangling handle.

Borrow the handle in both functions instead and add tor_client_free() as
the single place where ownership is taken back. The Dart wrapper frees
the client when the proxy is stopped and nulls the pointer so the existing
ClientNotActive guard covers later calls.

Adds unit tests that exercise repeated calls through one handle, and a
failed tor_client_bootstrap() followed by dormancy calls, each ending in
an explicit free. The bootstrap test corrupts the directory cache after
the client opens it, so bootstrap fails at once without network access.
Both segfault against the previous implementation.
arti 1.4.6 -> 2.6.0, arti-client/tor-rtcompat/tor-config 0.33 -> 0.46.
socks::run_socks_proxy() binds its listeners inside the future, and that
future ran in the task spawned by start_proxy(), so a bind failure
(typically EADDRINUSE on the port Dart picked moments earlier) ended up
as the JoinHandle's result and nothing ever looked at it. tor_start()
still returned live handles, tor_client_bootstrap() reported success
because it only checks the client, and Dart published a proxy port with
nothing listening on it.

Bind on the proxy runtime synchronously in tor_start(), set LAST_ERROR
and return null handles when that fails, and hand the already bound
listeners to socks::run_socks_proxy_with_listeners(). Dart already treats
a null client as a start failure and surfaces the Rust error, so no
wrapper change is needed. libc becomes a direct dependency for the
EAFNOSUPPORT check that run_socks_proxy() does internally.

Unit tests cover the occupied-port failure and that a successful bind
holds the port until the listeners are dropped.
Lockfile-only, semver-compatible updates:

  bytes              1.6.0   -> 1.12.1   RUSTSEC-2026-0007, yanked
  crossbeam-channel  0.5.13  -> 0.5.17   RUSTSEC-2025-0024, yanked
  openssl            0.10.64 -> 0.10.81  RUSTSEC-2024-0357, -2025-0004,
                                         -2025-0022
  openssl-sys        0.9.102 -> 0.9.117  required by openssl
  openssl-src        3.3.1   -> 3.6.3    vendored OpenSSL used on Linux
                                         and Android; 3.3.1 predates
                                         several OpenSSL security releases

None of these looked reachable in a practical way from the plugin, but
the versions were flagged in review and two were yanked.
Reconcile the handle-lifetime and listener-readiness fixes with the arti
2.6.0 API: the client handle is Arc<TorClient<..>> here, the proxy entry
point is arti::proxy::run_proxy_with_listeners() with ListenProtocols,
and tor_rtcompat's listen() takes TcpListenOptions. Changelog entries move
from Unreleased into 0.2.0.
Brings in the security-advisory lockfile update (bytes, crossbeam-channel,
openssl, openssl-sys, openssl-src). The lockfile merged cleanly and
resolves to the same versions as running the update directly on this
branch.
@sneurlax
sneurlax force-pushed the feat/bump-arti branch 2 times, most recently from 8a5f047 to 0ef60cf Compare October 6, 2026 21:34
stop() now resets the status, port and handles, so Tor can be started
again after it was stopped.

Resetting the status alone let a start() that was still in flight finish
after stop() and publish its handles over those of a newer start(),
leaving a running Tor client and proxy that nothing could release. Run
start() and stop() one at a time, in call order, through a queue: a
stop() issued during a pending start() waits for it and then tears down
what it started, and a start() issued after that stop() begins only once
the stop has finished.

Native calls go through a TorNative class that tests can replace, so the
ordering tests run deterministically without Tor. The restart test
against the real network runs when TOR_NETWORK_TESTS is set.

Route greetings through the same injectable native layer and cover the
substitution without loading the real library.
The locked num-bigint-dig 0.8.4 was incompatible with the compiler in use.
Update it to 0.8.6 and refresh its checksum and resolved dependencies.
A start() made while another was in flight returned immediately because
the status was already "starting", so its caller went on to read port
before Tor was ready and got an exception.

Keep the pending attempt and hand it to later start() callers, so they
all complete when Tor is up or fail with the same error. A stop() clears
the pending attempt, so a start() requested after it begins a fresh one
instead of joining the attempt that stop() is about to tear down.
start() stored the client and proxy handles before bootstrapping. When
bootstrap then failed it only set the status back to off, so the proxy
kept running and the next start() overwrote both handles, leaking a Tor
client nothing could free.

Tear down whatever the native start produced before rethrowing.
disable() is documented to prevent traffic flowing through the proxy but
only set the Dart status to off. The SOCKS listener kept accepting and
relaying connections, so a caller that relied on disable() alone stayed
on Tor while the status said otherwise.

Make disable() an alias for stop(). It now returns a Future; existing
callers that ignore the result, or follow it with stop() as Stack Wallet
does, keep working because stop() is idempotent and queued.
Dart probed a free port before Rust bound it, allowing another process to
claim it. Allocate the port while binding the IPv4 listener, reserve the
same port on available IPv6 loopback, and return the bound port to Dart.

Retry an OS-assigned port when another process holds its IPv6 twin, with a
bounded error after four attempts. Never retry a caller-requested port.
Recognize disabled IPv6 on Windows and fall back to IPv4 only when IPv6
is unavailable. Cover fallback decisions and occupied-port errors.
Both paths were allocated with toNativeUtf8() and never freed. tor_start()
only needs them for the duration of the call (it copies them into the
arti config), so free them in a finally once it returns, on success and
on failure.
tor_last_error_message() hands out a CString allocated by Rust, but no
function existed to give it back, so Dart copied the message and leaked
the allocation on every reported error.

Export tor_string_free() and call it from throwRustException() once the
message has been copied. Bindings and header are regenerated. Adds a Rust
round-trip test and a Dart test that drives a real tor_start() failure on
an occupied port, which exercises this path without network access.
tor_last_error_message() unwrapped CString::new(), which fails on an
interior NUL. The resulting panic would abort the process at the FFI
boundary. Drop NULs before building the C string and add a Rust test.
util.rs uses update_last_error only in the nofile-limit functions, which
are compiled out on Windows, so every Windows build warned about an unused
import.
Tor.stop() called tor_client_free() synchronously on the calling isolate and
did not wait for anything. Releasing the client is about to shut down its
runtime, which can take a few seconds, so run it through Isolate.run() the
way start already runs tor_start(), and complete stop() only once it
returns. A failed start waits for the same cleanup before rethrowing.

The test fake now completes freeClient() asynchronously, so the ordering
tests fail if a caller stops waiting for it.
Unbounded bootstrap serialized stop behind network work, and incomplete
teardown left clients alive and data directories locked. Give each client
a cancellable deadline and an owned runtime, activate the proxy only after
bootstrap, and join its accept loop before releasing the client.

Join starts with identical raw arguments, resolve physical directories
asynchronously inside the queue, and cancel every pending start on stop.
Reuse a running instance only for its physical directory. Retain failed
releases in order and retry before opening another client; keep status on
until a running instance is released, or starting after failed startup
cleanup. Never use lifecycle status as a proxy liveness monitor.

Preserve typed bootstrap failures and their stacks if cleanup also fails,
report secondary errors, and return the native cause chain without repeating
embedded suffixes. Namespace C status constants, preserve numeric ABI and
Dart names, and compile-check C/C++ interoperability. Include deterministic
lifecycle, error-chain and header regressions, documentation and release
notes describing the completed behavior.
sourceFingerprint() decoded every file under rust/src and rust/patches
as UTF-8, so a stray .DS_Store failed the build. Source builds now use
the fingerprint for their dependencies, which spread the failure from
prebuilt builds to every build. Even a decodable stray file changed the
hash and made a valid prebuilt manifest look mismatched.

Skip entries with a hidden path segment, and hash raw bytes with CRLF
folded to LF. The hash of valid sources is unchanged.
Exclude vendored Cargo output from fingerprints and avoid hashing all Rust inputs during source builds while retaining full prebuilt verification.
Use the 0.47.0 Arti crates and Rust 1.92.0. Rebase the Windows clock-resolution fix onto saturating-time 0.5.0, which still needs the no-progress guard, and exercise 100ns rounding on every host.

Verified Rust tests, patched-crate tests, Clippy, and 11 offline Flutter tests (one live-network test skipped); Dart analysis is clean.

Do not pin tor-config directly; nothing in the crate uses it.
The example used the old socket API, duplicated initial startup, and left
async lifecycle failures uncaught. Adopt socks_socket 2.0.0, await request
writes and shutdown, and display start and stop exceptions for retry.

Keep Stop available after failed release, disable requests during teardown,
clear errors after success, and guard state updates after disposal. Cover
write failures, lifecycle error display, retry and disposal with widget tests.
Keep the existing release symbol configuration while checking whether
packaged macOS native assets can use Cargo's default debuginfo stripping.
An optional CI comparison records exported symbols, loading, and size before
removing or narrowing this setting.
Update semver-compatible lockfile entries for anyhow, event-listener, memmap2, keccak, rand, bytemuck, and subtle. This resolves the fixable unsoundness and yanked-version findings in the current RustSec database.
Keep the reviewed saturating-time 0.5.0 fork vendored under rust/patches
at cypherstack/tor e4c225d, so source builds fetch only from crates.io
and do not depend on a git branch staying alive. Track it in the prebuilt
source fingerprint, but leave out the target directory and Cargo.lock
that building the crate on its own leaves behind, and ignore both in git.
Check Rust and Dart, root and example tests, Windows time/IPv6/file release,
and packaged applications on pull requests. Reserve the full native target
matrix for release tags and manual dispatches. Audit dependencies using a
cached pinned executable without granting verification write access.

Share pinned toolchain installation and desktop library loading checks.
Keep packaged macOS export validation and offer an optional default-versus-
none stripping comparison that records exports, loading, and size.
CfgPath::new expanded dollar variables and tilde prefixes in caller-provided
paths, rejecting otherwise valid directory names. Use CfgPath::new_literal
for both state and cache paths. Cover literal directory creation and cleanup
with a native regression and describe the behavior in the release notes.
sourceFingerprint() hashed rust/src but not rust/patches, so editing the
vendored saturating-time crate left the prebuilt fingerprint unchanged.
A stale prebuilt would still verify and Flutter's hook cache would not
rebuild. Hash and track rust/patches the same way as rust/src.

Source builds declare the same inputs too: cargo's dep-info, which
RustBuilder tracks, lists only this crate's own sources.
@sneurlax
sneurlax force-pushed the feat/bump-arti branch 3 times, most recently from ef6a8a6 to ef2a842 Compare October 15, 2026 21:02
…ield

The Bitcoin onion button checked the HTTP onion field, so a Bitcoin entry
without a port threw from int.parse and left its socket open. Each test now
closes its socket or HTTP client in a finally block.
Measured on macOS 26 with Xcode 26.6, Rust 1.92.0 and Flutter 3.47.2: the
default, "debuginfo" and "symbols" settings keep every exported tor_*
function on aarch64 and x86_64, in plain, prebuilt-style and hook builds,
and the packaged example starts Tor. rustc runs rust-objcopy --strip-debug
(or --discard-all for "symbols"), which leaves global symbols, and Flutter
strips packaged dylibs with strip -x -S anyway.
@sneurlax sneurlax changed the title Bump Arti and handle FFI lifetime Bump Arti to 2.7.0, fix FFI handle lifetimes, and harden Tor lifecycle Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant