diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ac735a8c..2c57a437 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -43,6 +43,11 @@ jobs: sudo xcode-select -s "$XCODE" xcodebuild -version + - name: Run native stamp job regressions + run: | + set -euo pipefail + swift test --filter NativeStampJobTests + - name: Resolve shipping Swift packages run: | set -euo pipefail diff --git a/Package.swift b/Package.swift index 8dc49e2c..1316b3f5 100644 --- a/Package.swift +++ b/Package.swift @@ -64,5 +64,10 @@ let package = Package( dependencies: ["RNSAPI"], path: "Tests/RNSAPITests" ), + .testTarget( + name: "SwiftBLEBridgeTests", + dependencies: ["SwiftBLEBridge"], + path: "Tests/SwiftBLEBridgeTests" + ), ] ) diff --git a/Sources/ColumbaApp/Resources/ColumbaApp.exports b/Sources/ColumbaApp/Resources/ColumbaApp.exports index 03e802b2..8ee57897 100644 --- a/Sources/ColumbaApp/Resources/ColumbaApp.exports +++ b/Sources/ColumbaApp/Resources/ColumbaApp.exports @@ -28,4 +28,8 @@ _columba_rnode_set_online _columba_rnode_state _columba_rnode_write _columba_stamp_generate -_columba_stamp_generate_cancellable +_columba_stamp_job_cancel +_columba_stamp_job_poll +_columba_stamp_job_release +_columba_stamp_job_start +_columba_stamp_jobs_cancel_all diff --git a/Sources/SwiftBLEBridge/StampGenerator.swift b/Sources/SwiftBLEBridge/StampGenerator.swift index 6d976def..6a9166bf 100644 --- a/Sources/SwiftBLEBridge/StampGenerator.swift +++ b/Sources/SwiftBLEBridge/StampGenerator.swift @@ -38,23 +38,19 @@ enum StampGenerator { /// `cost` leading zero bits. Multi-threaded across (capped) cores; blocks /// until found. Returns nil for an out-of-range / infeasible cost. /// - /// `maxCost` is a hard fail-fast bound: this runs synchronously from Python - /// via ctypes (holding the GIL for the whole call), so an infeasible cost - /// would freeze all RNS I/O — message delivery, announces, link events — - /// with no way to cancel. The practical LXMF range is 0–22 bits (Sideband's - /// default); 32 leaves generous headroom (~10 bits, 1024×) while keeping - /// the worst case bounded. (Greptile suggested 64, but 2^33–2^64 work is - /// hours-to-centuries — still an indefinite GIL freeze — so 32 is the - /// defensible ceiling; a message requesting more is undeliverable anyway.) + /// `maxCost` is a hard fail-fast bound for both the legacy synchronous ABI + /// and native asynchronous jobs. The practical LXMF range is 0–22 bits; + /// 32 leaves generous headroom while preventing nonsensical or malicious + /// requests from creating effectively unbounded work. static let maxCost = 32 static func generate( workblock: Data, cost: Int, - isCancelled: @escaping () -> Bool = { false } + cancellation: StampCancellationState = StampCancellationState() ) -> Data? { guard cost >= 0, cost <= maxCost else { return nil } - guard !isCancelled() else { return nil } + guard !cancellation.isCancelled else { return nil } if cost == 0 { return Data(repeating: 0, count: stampSize) } // Absorb the workblock once. CC_SHA256_CTX is a flat value struct with @@ -90,7 +86,7 @@ enum StampGenerator { if leadingZeroBits(digest) >= cost { // Cancellation wins if it raced the proof. StampResultBox // also refuses a proof after any sibling observed cancel. - if isCancelled() { + if cancellation.isCancelled { result.cancel() } else { result.trySet(Data(candidate)) @@ -107,10 +103,10 @@ enum StampGenerator { } rounds &+= 1 - // Poll foreign cancellation at a bounded interval rather than - // crossing the Python C callback boundary for every candidate. + // Poll native cancellation at a bounded interval rather than + // taking the cancellation lock for every candidate. if rounds & 0xFF == 0, // at most 256 candidates per worker - result.shouldStop(isCancelled: isCancelled) { + result.shouldStop(cancellation: cancellation) { return } } @@ -130,6 +126,22 @@ enum StampGenerator { } } +/// Native cancellation state shared by the job registry and every PoW worker. +/// Python changes this state only through a signed C-ABI cancel function. No +/// dynamically generated Python callback or executable heap trampoline crosses +/// into Swift. +final class StampCancellationState: @unchecked Sendable { + private let cancelled = OSAllocatedUnfairLock(initialState: false) + + var isCancelled: Bool { + cancelled.withLock { $0 } + } + + func cancel() { + cancelled.withLock { $0 = true } + } +} + /// Thread-safe shared search state. Cancellation and proof publication are /// linearized by the same lock, so a proof can never overwrite observed cancel. private final class StampResultBox: @unchecked Sendable { @@ -152,9 +164,9 @@ private final class StampResultBox: @unchecked Sendable { state.withLock { $0.cancelled = true; $0.stamp = nil } } - func shouldStop(isCancelled: () -> Bool) -> Bool { + func shouldStop(cancellation: StampCancellationState) -> Bool { if state.withLock({ $0.cancelled || $0.stamp != nil }) { return true } - if isCancelled() { + if cancellation.isCancelled { cancel() return true } @@ -192,42 +204,207 @@ public func columba_stamp_generate( return Int32(StampGenerator.stampSize) } -/// C-compatible cooperative cancellation callback. A non-zero result requests -/// that all native workers stop and that no stamp be returned. -public typealias ColumbaStampCancellationCallback = @convention(c) ( - UnsafeMutableRawPointer? -) -> Int32 - -/// Token-aware C ABI used by LXMF's three-argument external generator contract. -/// The legacy `columba_stamp_generate` symbol remains available for callers -/// that do not support cancellation. -@_cdecl("columba_stamp_generate_cancellable") -public func columba_stamp_generate_cancellable( - _ workblock: UnsafePointer?, - _ workblockLen: Int32, - _ stampCost: Int32, - _ outStamp: UnsafeMutablePointer?, - _ cancellationCallback: ColumbaStampCancellationCallback?, - _ cancellationContext: UnsafeMutableRawPointer? -) -> Int32 { - guard let workblock, let outStamp, let cancellationCallback, - workblockLen >= 0, stampCost >= 0 else { - return -1 +/// A native asynchronous PoW operation. The registry owns it while Python polls; +/// the worker closure also retains it until every native worker has stopped. +enum NativeStampJobStatus: Equatable { + case running + case succeeded(Data) + case cancelled + case failed +} + +final class NativeStampJob: @unchecked Sendable { + private let state = OSAllocatedUnfairLock(initialState: .running) + private let cancellation = StampCancellationState() + private let completion = DispatchSemaphore(value: 0) + private let workblock: Data + private let cost: Int + + init(workblock: Data, cost: Int) { + self.workblock = workblock + self.cost = cost } - let wb = workblock.withMemoryRebound(to: UInt8.self, capacity: Int(workblockLen)) { - Data(bytes: $0, count: Int(workblockLen)) + + func start() { + DispatchQueue.global(qos: .userInitiated).async { [self] in + defer { completion.signal() } + let stamp = StampGenerator.generate( + workblock: workblock, + cost: cost, + cancellation: cancellation + ) + state.withLock { status in + guard status == .running else { return } + if cancellation.isCancelled { + status = .cancelled + } else if let stamp { + status = .succeeded(stamp) + } else { + status = .failed + } + } + } } - guard let stamp = StampGenerator.generate( - workblock: wb, - cost: Int(stampCost), - isCancelled: { cancellationCallback(cancellationContext) != 0 } - ), stamp.count == StampGenerator.stampSize else { - return 0 + + func cancel() { + state.withLock { status in + cancellation.cancel() + status = .cancelled + } } - stamp.withUnsafeBytes { raw in - outStamp.withMemoryRebound(to: UInt8.self, capacity: StampGenerator.stampSize) { dst in - _ = memcpy(dst, raw.baseAddress!, StampGenerator.stampSize) + + /// Poll and copy under the same job lock used by cancellation. This is the + /// linearization point: a completed cancel can never be followed by a poll + /// that publishes a previously snapshotted proof. + func poll(into outStamp: UnsafeMutablePointer) -> Int32 { + // Carry the address as a Sendable integer through the lock closure. The + // caller owns this fixed 32-byte buffer for the duration of the C call. + let outStampAddress = UInt(bitPattern: outStamp) + return state.withLock { status in + switch status { + case .running: + return 0 + case .cancelled: + return -2 + case .failed: + return -3 + case .succeeded(let stamp): + guard stamp.count == StampGenerator.stampSize, + let destination = UnsafeMutablePointer( + bitPattern: outStampAddress + ) else { + return -3 + } + stamp.withUnsafeBytes { raw in + destination.withMemoryRebound( + to: UInt8.self, + capacity: StampGenerator.stampSize + ) { dst in + _ = memcpy(dst, raw.baseAddress!, StampGenerator.stampSize) + } + } + return Int32(StampGenerator.stampSize) + } } } - return Int32(StampGenerator.stampSize) + + func waitForCompletion(timeout: DispatchTime) -> Bool { + completion.wait(timeout: timeout) == .success + } +} + +/// Process-wide native job ownership for the synchronous Python external-stamper +/// contract. Registry operations never wait for PoW and never hold this lock +/// while cancelling a job. +enum NativeStampJobRegistry { + private struct RegistryState { + var nextID: UInt64 = 1 + var jobs: [UInt64: NativeStampJob] = [:] + } + + private static let state = OSAllocatedUnfairLock( + initialState: RegistryState() + ) + + static func start(workblock: Data, cost: Int) -> UInt64 { + guard cost >= 0, cost <= StampGenerator.maxCost else { return 0 } + let job = NativeStampJob(workblock: workblock, cost: cost) + let jobID = state.withLock { registry -> UInt64 in + var candidate = registry.nextID + repeat { + if candidate == 0 { candidate = 1 } + if registry.jobs[candidate] == nil { break } + candidate &+= 1 + } while true + registry.jobs[candidate] = job + registry.nextID = candidate &+ 1 + if registry.nextID == 0 { registry.nextID = 1 } + return candidate + } + job.start() + return jobID + } + + static func poll( + _ jobID: UInt64, + into outStamp: UnsafeMutablePointer + ) -> Int32? { + let job = state.withLock { $0.jobs[jobID] } + return job?.poll(into: outStamp) + } + + @discardableResult + static func cancel(_ jobID: UInt64) -> Bool { + let job = state.withLock { $0.jobs[jobID] } + guard let job else { return false } + job.cancel() + return true + } + + @discardableResult + static func release(_ jobID: UInt64) -> Bool { + let job = state.withLock { $0.jobs.removeValue(forKey: jobID) } + guard let job else { return false } + job.cancel() + return true + } + + static func cancelAll() -> Int { + let jobs = state.withLock { registry -> [NativeStampJob] in + let jobs = Array(registry.jobs.values) + registry.jobs.removeAll(keepingCapacity: true) + return jobs + } + for job in jobs { job.cancel() } + return jobs.count + } +} + +/// Start a native asynchronous stamp operation and return its process-local job +/// ID. Zero means invalid arguments or an unsupported cost. +@_cdecl("columba_stamp_job_start") +public func columba_stamp_job_start( + _ workblock: UnsafePointer?, + _ workblockLen: Int32, + _ stampCost: Int32 +) -> UInt64 { + guard let workblock, workblockLen >= 0, stampCost >= 0 else { return 0 } + let data = workblock.withMemoryRebound(to: UInt8.self, capacity: Int(workblockLen)) { + Data(bytes: $0, count: Int(workblockLen)) + } + return NativeStampJobRegistry.start(workblock: data, cost: Int(stampCost)) +} + +/// Poll a native job. Returns 0 while running, 32 after copying a completed +/// stamp, -2 when cancelled, -3 on generation failure, and -1 for bad input or +/// an unknown/released job. +@_cdecl("columba_stamp_job_poll") +public func columba_stamp_job_poll( + _ jobID: UInt64, + _ outStamp: UnsafeMutablePointer? +) -> Int32 { + guard jobID != 0, let outStamp, + let status = NativeStampJobRegistry.poll(jobID, into: outStamp) else { + return -1 + } + return status +} + +/// Request cooperative cancellation. Repeated cancellation remains successful +/// until Python releases the job. +@_cdecl("columba_stamp_job_cancel") +public func columba_stamp_job_cancel(_ jobID: UInt64) -> Int32 { + NativeStampJobRegistry.cancel(jobID) ? 0 : -1 +} + +/// End Python ownership. Releasing an active job also cancels its native work. +@_cdecl("columba_stamp_job_release") +public func columba_stamp_job_release(_ jobID: UInt64) -> Int32 { + NativeStampJobRegistry.release(jobID) ? 0 : -1 +} + +/// Cancel and detach every active job during embedded-runtime teardown. +@_cdecl("columba_stamp_jobs_cancel_all") +public func columba_stamp_jobs_cancel_all() -> Int32 { + Int32(NativeStampJobRegistry.cancelAll()) } diff --git a/Tests/SwiftBLEBridgeTests/NativeStampJobTests.swift b/Tests/SwiftBLEBridgeTests/NativeStampJobTests.swift new file mode 100644 index 00000000..9b8f99e5 --- /dev/null +++ b/Tests/SwiftBLEBridgeTests/NativeStampJobTests.swift @@ -0,0 +1,155 @@ +import CryptoKit +import XCTest +@testable import SwiftBLEBridge + +final class NativeStampJobTests: XCTestCase { + override func setUp() { + super.setUp() + _ = columba_stamp_jobs_cancel_all() + } + + override func tearDown() { + _ = columba_stamp_jobs_cancel_all() + super.tearDown() + } + + func testZeroCostJobProducesACompleteStamp() throws { + let jobID = startJob(workblock: Data("workblock".utf8), cost: 0) + XCTAssertNotEqual(jobID, 0) + defer { XCTAssertEqual(columba_stamp_job_release(jobID), 0) } + + let result = try waitForTerminalStatus(jobID) + XCTAssertEqual(result.status, Int32(StampGenerator.stampSize)) + XCTAssertEqual(result.stamp, Data(repeating: 0, count: StampGenerator.stampSize)) + } + + func testNonzeroCostJobProducesAValidProof() throws { + let workblock = Data("native-stamp-regression".utf8) + let cost: Int32 = 8 + let jobID = startJob(workblock: workblock, cost: cost) + XCTAssertNotEqual(jobID, 0) + defer { XCTAssertEqual(columba_stamp_job_release(jobID), 0) } + + let result = try waitForTerminalStatus(jobID) + XCTAssertEqual(result.status, Int32(StampGenerator.stampSize)) + XCTAssertEqual(result.stamp.count, StampGenerator.stampSize) + let digest = SHA256.hash(data: workblock + result.stamp) + XCTAssertGreaterThanOrEqual(leadingZeroBits(Array(digest)), Int(cost)) + } + + func testCancellationWinsAndRemainsObservableUntilRelease() throws { + let jobID = startJob(workblock: Data("cancel-me".utf8), cost: 32) + XCTAssertNotEqual(jobID, 0) + + XCTAssertEqual(columba_stamp_job_cancel(jobID), 0) + XCTAssertEqual(columba_stamp_job_cancel(jobID), 0) + let result = try waitForTerminalStatus(jobID) + XCTAssertEqual(result.status, -2) + + XCTAssertEqual(columba_stamp_job_release(jobID), 0) + XCTAssertEqual(columba_stamp_job_cancel(jobID), -1) + } + + func testReleaseCancelsAndDetachesAnActiveJob() { + let jobID = startJob(workblock: Data("release-me".utf8), cost: 32) + XCTAssertNotEqual(jobID, 0) + XCTAssertEqual(columba_stamp_job_release(jobID), 0) + + var output = [CChar](repeating: 0, count: StampGenerator.stampSize) + let status = output.withUnsafeMutableBufferPointer { + columba_stamp_job_poll(jobID, $0.baseAddress) + } + XCTAssertEqual(status, -1) + XCTAssertEqual(columba_stamp_job_release(jobID), -1) + } + + func testCancelledWorkerActuallyStops() { + let job = NativeStampJob(workblock: Data("stop-worker".utf8), cost: 32) + job.start() + job.cancel() + XCTAssertTrue(job.waitForCompletion(timeout: .now() + 1)) + var output = [CChar](repeating: 0, count: StampGenerator.stampSize) + let status = output.withUnsafeMutableBufferPointer { + job.poll(into: $0.baseAddress!) + } + XCTAssertEqual(status, -2) + } + + func testCancelAllDetachesEveryActiveJobWithoutAffectingFutureJobs() throws { + let first = startJob(workblock: Data("first".utf8), cost: 32) + let second = startJob(workblock: Data("second".utf8), cost: 32) + XCTAssertNotEqual(first, 0) + XCTAssertNotEqual(second, 0) + XCTAssertEqual(columba_stamp_jobs_cancel_all(), 2) + + var output = [CChar](repeating: 0, count: StampGenerator.stampSize) + for jobID in [first, second] { + let status = output.withUnsafeMutableBufferPointer { + columba_stamp_job_poll(jobID, $0.baseAddress) + } + XCTAssertEqual(status, -1) + } + + let replacement = startJob(workblock: Data("replacement".utf8), cost: 0) + XCTAssertNotEqual(replacement, 0) + defer { XCTAssertEqual(columba_stamp_job_release(replacement), 0) } + XCTAssertEqual(try waitForTerminalStatus(replacement).status, 32) + } + + func testInvalidCostDoesNotCreateAJob() { + XCTAssertEqual(startJob(workblock: Data("invalid".utf8), cost: -1), 0) + XCTAssertEqual( + startJob(workblock: Data("invalid".utf8), cost: Int32(StampGenerator.maxCost + 1)), + 0 + ) + } + + private func startJob(workblock: Data, cost: Int32) -> UInt64 { + workblock.withUnsafeBytes { raw in + columba_stamp_job_start( + raw.baseAddress?.assumingMemoryBound(to: CChar.self), + Int32(raw.count), + cost + ) + } + } + + private func waitForTerminalStatus( + _ jobID: UInt64, + timeout: TimeInterval = 2 + ) throws -> (status: Int32, stamp: Data) { + let deadline = Date().addingTimeInterval(timeout) + var output = [CChar](repeating: 0, count: StampGenerator.stampSize) + repeat { + let status = output.withUnsafeMutableBufferPointer { + columba_stamp_job_poll(jobID, $0.baseAddress) + } + if status != 0 { + return ( + status, + Data(output.map { UInt8(bitPattern: $0) }) + ) + } + Thread.sleep(forTimeInterval: 0.001) + } while Date() < deadline + XCTFail("native stamp job did not reach a terminal state before timeout") + throw TestError.timeout + } + + private func leadingZeroBits(_ bytes: [UInt8]) -> Int { + var count = 0 + for byte in bytes { + if byte == 0 { + count += 8 + } else { + count += byte.leadingZeroBitCount + break + } + } + return count + } + + private enum TestError: Error { + case timeout + } +} diff --git a/Tests/static/test_ci_artifact_isolation.py b/Tests/static/test_ci_artifact_isolation.py index 5dac4b75..ceff2a9e 100644 --- a/Tests/static/test_ci_artifact_isolation.py +++ b/Tests/static/test_ci_artifact_isolation.py @@ -171,6 +171,7 @@ def test_shipping_rejects_any_missing_python_native_symbol_or_rnode_payload(self b"_columba_ble_send", b"_columba_rnode_write", b"_columba_stamp_generate", + b"_columba_stamp_job_start", ): with self.subTest(symbol=symbol), tempfile.TemporaryDirectory() as directory: fixture = ArtifactFixture(Path(directory), "shipping") diff --git a/Tests/static/test_ios_stamp_runtime_hardening.py b/Tests/static/test_ios_stamp_runtime_hardening.py index 638eb289..21192116 100644 --- a/Tests/static/test_ios_stamp_runtime_hardening.py +++ b/Tests/static/test_ios_stamp_runtime_hardening.py @@ -105,7 +105,26 @@ def set_external_generator(cls, *args, **kwargs): self.stamper = Stamper self.bridge = load_bridge(Stamper) - def test_token_is_forwarded_and_cancellation_returns_no_stamp(self) -> None: + def configure_native_jobs( + self, + *, + start=None, + poll=None, + cancel=None, + release=None, + cancel_all=None, + ) -> None: + setattr(self.bridge, "_stamp_job_start_fn", start or mock.Mock(return_value=1)) + setattr(self.bridge, "_stamp_job_poll_fn", poll or mock.Mock(return_value=-2)) + setattr(self.bridge, "_stamp_job_cancel_fn", cancel or mock.Mock(return_value=0)) + setattr(self.bridge, "_stamp_job_release_fn", release or mock.Mock(return_value=0)) + setattr( + self.bridge, + "_stamp_jobs_cancel_all_fn", + cancel_all or mock.Mock(return_value=0), + ) + + def test_token_cancellation_cancels_and_releases_native_job(self) -> None: class Token: def __init__(self): self.checks = 0 @@ -115,20 +134,38 @@ def is_cancelled(self): return True token = Token() - seen = {} + events = [] + + def start(workblock, length, cost): + events.append(("start", bytes(workblock), length, cost)) + return 41 + + def cancel(job_id): + events.append(("cancel", job_id)) + return 0 + + def poll(job_id, _output): + events.append(("poll", job_id)) + return -2 - def native(workblock, length, cost, output, cancellation, context): - seen["args"] = (bytes(workblock), length, cost, context) - seen["cancelled"] = cancellation(context) + def release(job_id): + events.append(("release", job_id)) return 0 - self.bridge._stamp_generate_cancellable_fn = native + self.configure_native_jobs(start=start, poll=poll, cancel=cancel, release=release) self.assertIsNone(self.bridge._native_stamp_pow(b"work", 9, token)) - self.assertEqual((b"work", 4, 9, None), seen["args"]) - self.assertEqual(1, seen["cancelled"]) + self.assertEqual( + [ + ("start", b"work", 4, 9), + ("cancel", 41), + ("poll", 41), + ("release", 41), + ], + events, + ) self.assertEqual(1, token.checks) - def test_valid_nonzero_cost_native_proof_path_returns_exact_stamp(self) -> None: + def test_valid_nonzero_cost_native_job_returns_exact_stamp(self) -> None: workblock = b"payload" cost = 7 candidate_number = 0 @@ -140,22 +177,46 @@ def test_valid_nonzero_cost_native_proof_path_returns_exact_stamp(self) -> None: break candidate_number += 1 - def native(_workblock, _length, native_cost, output, cancellation, context): - self.assertEqual(cost, native_cost) - self.assertEqual(0, cancellation(context)) + released = [] + + def poll(job_id, output): + self.assertEqual(73, job_id) ctypes.memmove(output, expected, len(expected)) return len(expected) - self.bridge._stamp_generate_cancellable_fn = native + self.configure_native_jobs( + start=mock.Mock(return_value=73), + poll=poll, + release=lambda job_id: released.append(job_id) or 0, + ) token = types.SimpleNamespace(is_cancelled=lambda: False) stamp = self.bridge._native_stamp_pow(workblock, cost, token) self.assertEqual(expected, stamp) + self.assertEqual([73], released) proof = hashlib.sha256(workblock + stamp).digest() self.assertGreaterEqual(256 - int.from_bytes(proof, "big").bit_length(), cost) + def test_unexpected_positive_poll_status_fails_closed(self) -> None: + polls = [] + released = [] + + def poll(job_id, _output): + polls.append(job_id) + return 1 if len(polls) == 1 else -2 + + self.configure_native_jobs( + start=mock.Mock(return_value=91), + poll=poll, + release=lambda job_id: released.append(job_id) or 0, + ) + token = types.SimpleNamespace(is_cancelled=lambda: False) + self.assertIsNone(self.bridge._native_stamp_pow(b"work", 8, token)) + self.assertEqual([91], polls) + self.assertEqual([91], released) + def test_install_requests_three_argument_contract_and_forwards_exact_token(self) -> None: token = object() - self.bridge._stamp_generate_cancellable_fn = object() + self.configure_native_jobs() self.bridge._native_stamp_pow = mock.Mock(return_value=b"x" * 32) self.bridge._install_native_stamp_generator() @@ -166,15 +227,21 @@ def test_install_requests_three_argument_contract_and_forwards_exact_token(self) self.bridge._native_stamp_pow.assert_called_once_with(b"block", 5, token) def test_repeated_install_uninstall_does_not_retain_global_callback(self) -> None: - self.bridge._stamp_generate_cancellable_fn = object() + self.configure_native_jobs() for _ in range(2): self.bridge._install_native_stamp_generator() self.bridge._uninstall_native_stamp_generator() self.assertIsNone(self.stamper.calls[-1][0][0]) self.assertEqual(4, len(self.stamper.calls)) + def test_cancel_all_failure_does_not_retain_global_callback(self) -> None: + self.configure_native_jobs(cancel_all=mock.Mock(side_effect=RuntimeError("boom"))) + self.bridge._install_native_stamp_generator() + self.bridge._uninstall_native_stamp_generator() + self.assertIsNone(self.stamper.calls[-1][0][0]) + def test_repeated_stop_when_not_started_still_clears_global_callback(self) -> None: - self.bridge._stamp_generate_cancellable_fn = object() + self.configure_native_jobs() self.bridge._install_native_stamp_generator() self.bridge._state["started"] = False self.bridge.stop() @@ -232,13 +299,31 @@ def test_reset_balances_teardown_publication_when_cleanup_raises(self) -> None: class NativeStampStaticABITests(unittest.TestCase): - def test_swift_has_cancellable_abi_and_thread_safe_periodic_polling(self) -> None: + def test_shipping_stamp_bridge_uses_no_python_callback_trampoline(self) -> None: + forbidden = re.compile(r"\b(?:CFUNCTYPE|PYFUNCTYPE)\b") + offenders = [] + for path in sorted((ROOT / "app").rglob("*.py")): + if forbidden.search(path.read_text(encoding="utf-8")): + offenders.append(str(path.relative_to(ROOT))) + self.assertEqual([], offenders, "shipping Python creates a native callback trampoline") + + def test_swift_has_native_job_abi_and_thread_safe_periodic_polling(self) -> None: source = STAMP.read_text(encoding="utf-8") - self.assertIn('@_cdecl("columba_stamp_generate")', source) - self.assertIn('@_cdecl("columba_stamp_generate_cancellable")', source) - self.assertIn("isCancelled:", source) + for symbol in ( + "columba_stamp_generate", + "columba_stamp_job_start", + "columba_stamp_job_poll", + "columba_stamp_job_cancel", + "columba_stamp_job_release", + "columba_stamp_jobs_cancel_all", + ): + self.assertIn(f'@_cdecl("{symbol}")', source) + self.assertNotIn("columba_stamp_generate_cancellable", source) + self.assertNotIn("@convention(c)", source) + self.assertIn("StampCancellationState", source) self.assertRegex(source, r"rounds\s*&\s*0x(?:FF|[1-9A-F][0-9A-F]+)\s*==\s*0") self.assertIn("OSAllocatedUnfairLock", source) + self.assertIn("func poll(into outStamp:", source) self.assertNotRegex(source, r"nonatomic|UnsafeMutablePointer") def test_shutdown_paths_unregister_before_taking_bridge_lock(self) -> None: diff --git a/app/rns_bridge.py b/app/rns_bridge.py index 866c56c9..20e6deb5 100644 --- a/app/rns_bridge.py +++ b/app/rns_bridge.py @@ -74,8 +74,8 @@ def flush(self) -> None: # LXStamper's macOS `job_simple` branch.) We offload the proof-of-work to a # native, multi-threaded Swift implementation reached via ctypes — the iOS # analog of Columba Android's `event_bridge.install_external_stamp_generator` -# + Kotlin `StampGenerator`. `columba_stamp_generate` is a @_cdecl shim in -# SwiftBLEBridge (statically linked → resolvable through `CDLL(None)`). +# + Kotlin `StampGenerator`. The `columba_stamp_job_*` @_cdecl shims live in +# SwiftBLEBridge and are statically linked so `CDLL(None)` can resolve them. import ctypes try: @@ -84,53 +84,110 @@ def flush(self) -> None: _columba_lib = None -_StampCancellationCallback = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p) - - -def _bind_stamp_fn(symbol: str, cancellable: bool = False): +def _bind_stamp_fn(symbol: str, argtypes: list[Any], restype: Any = ctypes.c_int32): if _columba_lib is None: return None try: fn = getattr(_columba_lib, symbol) except AttributeError: return None - # Legacy: (workblock, workblock_len, stamp_cost, out_stamp) -> bytes_written. - # Cancellable: the same arguments plus (callback, opaque_context). - fn.argtypes = [ctypes.c_char_p, ctypes.c_int32, ctypes.c_int32, ctypes.c_char_p] - if cancellable: - fn.argtypes.extend([_StampCancellationCallback, ctypes.c_void_p]) - fn.restype = ctypes.c_int32 + fn.argtypes = argtypes + fn.restype = restype return fn -_stamp_generate_fn = _bind_stamp_fn("columba_stamp_generate") -_stamp_generate_cancellable_fn = _bind_stamp_fn( - "columba_stamp_generate_cancellable", cancellable=True +# Native stamp jobs keep executable work and cancellation state in Swift. Python +# only calls signed C-ABI functions. In particular, never pass a ctypes callback +# into native code: executable callback trampolines can violate hardened iOS +# code-signing policy and terminate the process with an invalid-page error. +_stamp_job_start_fn = _bind_stamp_fn( + "columba_stamp_job_start", + [ctypes.c_char_p, ctypes.c_int32, ctypes.c_int32], + ctypes.c_uint64, +) +_stamp_job_poll_fn = _bind_stamp_fn( + "columba_stamp_job_poll", + [ctypes.c_uint64, ctypes.c_char_p], +) +_stamp_job_cancel_fn = _bind_stamp_fn( + "columba_stamp_job_cancel", + [ctypes.c_uint64], +) +_stamp_job_release_fn = _bind_stamp_fn( + "columba_stamp_job_release", + [ctypes.c_uint64], +) +_stamp_jobs_cancel_all_fn = _bind_stamp_fn( + "columba_stamp_jobs_cancel_all", + [], ) +def _native_stamp_jobs_available() -> bool: + return all( + fn is not None + for fn in ( + _stamp_job_start_fn, + _stamp_job_poll_fn, + _stamp_job_cancel_fn, + _stamp_job_release_fn, + _stamp_jobs_cancel_all_fn, + ) + ) + + +def _token_is_cancelled(cancellation_token: Any) -> bool: + """Treat an invalid or expired foreign token as cancellation.""" + try: + return bool(cancellation_token.is_cancelled()) + except Exception: + return True + + def _native_stamp_pow(workblock: bytes, stamp_cost: int, cancellation_token: Any): - """Run native multi-threaded PoW with cooperative LXMF cancellation.""" - if _stamp_generate_cancellable_fn is None: + """Run native multi-threaded PoW with job-owned native cancellation.""" + if not _native_stamp_jobs_available(): return None - @_StampCancellationCallback - def _is_cancelled(_context): - try: - return 1 if cancellation_token.is_cancelled() else 0 - except Exception: - # A broken/expired foreign token must fail closed, not leave an - # uncancellable native search running indefinitely. - return 1 + start_job = _stamp_job_start_fn + poll_job = _stamp_job_poll_fn + cancel_job = _stamp_job_cancel_fn + release_job = _stamp_job_release_fn + assert start_job is not None + assert poll_job is not None + assert cancel_job is not None + assert release_job is not None + + payload = bytes(workblock) + job_id = int(start_job(payload, len(payload), int(stamp_cost))) + if job_id == 0: + return None out = ctypes.create_string_buffer(32) - n = _stamp_generate_cancellable_fn( - bytes(workblock), len(workblock), int(stamp_cost), out, - _is_cancelled, None, - ) - if n == 32: - return out.raw[:32] - return None + try: + while True: + if _token_is_cancelled(cancellation_token): + cancel_job(job_id) + + status = int(poll_job(job_id, out)) + if status == 32: + # Close the race where cancellation arrives after the pre-poll + # check but before Python observes a completed native proof. + if _token_is_cancelled(cancellation_token): + cancel_job(job_id) + return None + return out.raw[:32] + if status < 0: + return None + if status != 0: + RNS.log( + f"native stamp gen: unexpected poll status {status}", + RNS.LOG_WARNING, + ) + return None + time.sleep(0.01) + finally: + release_job(job_id) def _install_native_stamp_generator() -> None: @@ -147,10 +204,10 @@ def _install_native_stamp_generator() -> None: except Exception as e: # noqa: BLE001 RNS.log(f"native stamp gen: LXStamper unavailable: {e}", RNS.LOG_DEBUG) return - if _stamp_generate_cancellable_fn is None: + if not _native_stamp_jobs_available(): RNS.log( - "native stamp gen: columba_stamp_generate_cancellable symbol not " - "found; stamp generation will fail on iOS", + "native stamp gen: native job symbols not found; stamp generation " + "will fail on iOS", RNS.LOG_WARNING, ) return @@ -187,6 +244,14 @@ def _external_generator(workblock, stamp_cost, cancellation_token): def _uninstall_native_stamp_generator() -> None: """Clear LXMF's process-global callback and cooperatively cancel its jobs.""" + try: + if _stamp_jobs_cancel_all_fn is not None: + _stamp_jobs_cancel_all_fn() + except Exception as e: # noqa: BLE001 + RNS.log(f"native stamp gen: cancel-all failed: {e}", RNS.LOG_DEBUG) + + # Always attempt process-global callback removal, even when native cleanup + # fails. Retaining it can reference stale embedded-runtime state. try: LXStamper = LXMF.LXStamper setter = getattr(LXStamper, "set_external_generator", None)