diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77023313..8f464b48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,10 @@ jobs: - uses: actions/checkout@v4 - name: Verify Apple app icon packaging run: python3 .agents/skills/journal-build/scripts/test_apple_app_icon.py + - name: Verify Swift runtime startup and failure boundaries + run: | + python3 tool/test_journal_runtime_startup.py + python3 tool/test_journal_runtime_failure.py - name: Prepare native system libraries before cache lookup run: | brew install pkg-config gmp sqlite zlib zstd diff --git a/apple-tests/platform-services/JournalRuntimeStartupBackend.swift b/apple-tests/platform-services/JournalRuntimeStartupBackend.swift new file mode 100644 index 00000000..a707c4b7 --- /dev/null +++ b/apple-tests/platform-services/JournalRuntimeStartupBackend.swift @@ -0,0 +1,13 @@ +import Foundation + +public struct LUIEvent: Sendable {} +public struct LUIAppleExtensionRegistry { public init() {} } +@MainActor public final class LUIAppleBackend { + public var rootIDs: [Int] = [] + public var onEvent: ((LUIEvent) -> Void)? + public init(appIcons: [String: String], extensionRegistry: LUIAppleExtensionRegistry) throws {} + public func apply(json: String) throws { + if json == "invalid" { throw NSError(domain: "fixture", code: 1) } + rootIDs = [42] + } +} diff --git a/apple-tests/platform-services/JournalRuntimeStartupBridge.c b/apple-tests/platform-services/JournalRuntimeStartupBridge.c new file mode 100644 index 00000000..95cc67a1 --- /dev/null +++ b/apple-tests/platform-services/JournalRuntimeStartupBridge.c @@ -0,0 +1,22 @@ +#include +#include +#include +static void (*request)(const char*,int32_t); static int mode,inside,calls,reentrant; +int32_t fixture_inside(void){return inside;} +int32_t fixture_calls(void){return calls;} +int32_t fixture_reentrant(void){return reentrant;} +void fixture_mode(int32_t m){mode=m;} +void fixture_emit(void){char bytes[]={1,2};request(bytes,2);memset(bytes,9,2);} +void* worker(void*x){fixture_emit();return 0;} +int32_t lui_ocaml_start(void (*patch)(const char*),int32_t p,int32_t h,const char*d,int32_t n){ + inside=1; + if(mode==3){pthread_t t;pthread_create(&t,0,worker,0);pthread_join(t,0);}else{fixture_emit();if(mode==4)fixture_emit();} + patch(mode==2?"invalid":"valid");inside=0;return mode==1?0:1; +} +int32_t lui_ocaml_stop(void){return 1;} +void journal_ocaml_set_wakeup_callback(void(*f)(void)){} +void journal_ocaml_set_platform_request_callback(void(*f)(const char*,int32_t)){request=f;} +int32_t journal_ocaml_pump(void){return 1;} +int32_t journal_ocaml_platform_event(const char*d,int32_t n){if(inside)reentrant++;return 1;} +int32_t journal_ocaml_platform_response(const char*d,int32_t n){calls++;if(inside)reentrant++;return 1;} +int32_t journal_ocaml_platform_failure(const char*d,int32_t n){calls++;if(inside)reentrant++;return 1;} diff --git a/apple-tests/platform-services/JournalRuntimeStartupTests.swift b/apple-tests/platform-services/JournalRuntimeStartupTests.swift new file mode 100644 index 00000000..b339b048 --- /dev/null +++ b/apple-tests/platform-services/JournalRuntimeStartupTests.swift @@ -0,0 +1,102 @@ +import Foundation +import LUIAppleBackend + +@_silgen_name("fixture_inside") func inside() -> Int32 +@_silgen_name("fixture_calls") func calls() -> Int32 +@_silgen_name("fixture_reentrant") func reentrant() -> Int32 +@_silgen_name("fixture_mode") func mode(_ m: Int32) +@_silgen_name("fixture_emit") func emit() +let journalAppIcons: [String: String] = [:] +@MainActor enum JournalLUIEvents { static func dispatch(_ e: LUIEvent) -> Int32 { 1 } } +@MainActor final class JournalApplicationPlatform { + var connected = false, hold = true + var begins = 0 + var waiters: [CheckedContinuation] = [] + func connect(to runtime: JournalRuntime) { connected = true } + func disconnect() { connected = false } + func request(_ bytes: Data) async -> Data? { + precondition(inside() == 0 && connected, "request before native start/connect finished") + precondition(Array(bytes) == [1, 2], "C bytes must be owned after callback") + begins += 1 + if hold { return await withCheckedContinuation { waiters.append($0) } } + return bytes + } +} +@MainActor @main struct Test { + static func main() async throws { + let scenario = CommandLine.arguments[1] + mode(["rejected": 1, "patch-failure": 2, "worker": 3, "multiple": 4][scenario] ?? 0) + let p = JournalApplicationPlatform() + let r = try JournalRuntime( + platform: p, startupPayload: Data(), extensionRegistry: LUIAppleExtensionRegistry()) + var published = false + let boot = Task { + try r.start() + try await r.waitForInitialPlatformRequests() + published = true + } + if scenario == "rejected" || scenario == "patch-failure" { + do { + try await boot.value + preconditionFailure("rejected startup completed") + } catch {} + for _ in 0..<12 { await Task.yield() } + precondition(!published && !p.connected && p.begins == 0 && calls() == 0) + } else { + if scenario == "repeated-start" { try r.start() } + let count = scenario == "multiple" ? 2 : 1 + for _ in 0..<200 { + if p.waiters.count == count { break } + await Task.yield() + } + precondition(p.begins == count, "buffered requests were not delivered exactly once") + precondition(!published, "root published before initial account request completed") + precondition(calls() == 0 && reentrant() == 0) + if scenario == "cancelled" { + boot.cancel() + for _ in 0..<100 { + if !p.connected { break } + await Task.yield() + } + precondition(!p.connected, "cancellation did not promptly stop the waiting runtime") + p.waiters.removeFirst().resume(returning: Data([1, 2])) + do { + try await boot.value + preconditionFailure("cancelled boot published") + } catch is CancellationError {} + precondition(calls() == 0 && !published) + } else if scenario == "stopped" || scenario == "stale-cancel" { + r.stop() + if scenario == "stale-cancel" { + try r.start() + for _ in 0..<100 { + if p.begins == 2 { break } + await Task.yield() + } + boot.cancel() + for _ in 0..<12 { await Task.yield() } + precondition(p.connected, "old cancellation stopped the new runtime generation") + } + p.waiters.removeFirst().resume(returning: Data([1, 2])) + do { + try await boot.value + preconditionFailure("stopped boot published") + } catch is CancellationError {} + precondition(calls() == 0 && !published) + if scenario == "stale-cancel" { + p.waiters.removeFirst().resume(returning: Data([1, 2])) + try await r.waitForInitialPlatformRequests() + precondition(calls() == 1 && p.connected) + r.stop() + } + } else { + for w in p.waiters { w.resume(returning: scenario == "nil-response" ? nil : Data([1, 2])) } + p.waiters = [] + try await boot.value + precondition(published && calls() == count && reentrant() == 0) + r.stop() + } + } + print("PASS barrier \(scenario)") + } +} diff --git a/swift/JournalRuntime.swift b/swift/JournalRuntime.swift index befef078..3a26b04f 100644 --- a/swift/JournalRuntime.swift +++ b/swift/JournalRuntime.swift @@ -55,15 +55,42 @@ private let wakeup: WakeupCallback = { } } -/// OCaml calls this synchronously on its worker thread with an LJP2 request -/// envelope. The callback returns immediately; the platform answers later via -/// `journal_ocaml_platform_response` on the main actor. +/// One bridge session owns both buffered startup requests and later deliveries. +/// A callback copies its payload before returning; it never enters OCaml again. +private final class JournalPlatformRequestIngress: @unchecked Sendable { + private let lock = NSLock() + private var owner: UUID? + private var pending: [Data] = [] + private var receiver: (@Sendable (Data) -> Void)? + func begin(_ identity: UUID) { + lock.lock(); defer { lock.unlock() } + owner = identity; pending = []; receiver = nil + } + func submit(_ request: Data) { + lock.lock() + guard owner != nil else { lock.unlock(); return } + let deliver = receiver + if deliver == nil { pending.append(request) } + lock.unlock() + deliver?(request) + } + func activate(_ identity: UUID, receiver: @escaping @Sendable (Data) -> Void) -> [Data] { + lock.lock(); defer { lock.unlock() } + guard owner == identity else { return [] } + self.receiver = receiver + let requests = pending; pending = [] + return requests + } + func close(_ identity: UUID) { + lock.lock(); defer { lock.unlock() } + guard owner == identity else { return } + owner = nil; receiver = nil; pending = [] + } +} +private let platformRequestIngress = JournalPlatformRequestIngress() private let platformRequest: PlatformRequestCallback = { data, length in guard let data, length > 0 else { return } - let bytes = Data(bytes: data, count: Int(length)) - Task { @MainActor in - await activeRuntime?.deliverPlatformRequest(bytes) - } + platformRequestIngress.submit(Data(bytes: data, count: Int(length))) } /// Owns the lui backend, the OCaml runtime, and the platform bridge for one @@ -90,6 +117,8 @@ private let platformRequest: PlatformRequestCallback = { data, length in private(set) var appliedPatches = 0 private(set) var failure: Failure? private var started = false + @ObservationIgnored private var generation = UUID() + @ObservationIgnored private var initialPlatformTasks: [Task] = [] private var stopPending = false init( @@ -112,6 +141,10 @@ private let platformRequest: PlatformRequestCallback = { data, length in func start() throws { if let failure { throw failure } guard !started else { return } + generation = UUID() + let identity = generation + platformRequestIngress.begin(identity) + defer { if !started { platformRequestIngress.close(identity) } } journalOCamlSetWakeupCallback(wakeup) journalOCamlSetPlatformRequestCallback(platformRequest) activeRuntime = self @@ -147,10 +180,42 @@ private let platformRequest: PlatformRequestCallback = { data, length in } started = true platform.connect(to: self) + let requests = platformRequestIngress.activate(identity) { [weak self] request in + Task { @MainActor [weak self] in + guard let self, self.generation == identity else { return } + await self.deliverPlatformRequest(request) + } + } + initialPlatformTasks = requests.map { request in + Task { @MainActor [weak self] in + guard let self, self.generation == identity else { return } + await self.deliverPlatformRequest(request) + } + } + } + + /// Waits for requests emitted during admission, after native start/connect. + /// Mounting the root afterwards lets local initialization precede UI work. + func waitForInitialPlatformRequests() async throws { + let identity = generation, tasks = initialPlatformTasks + try await withTaskCancellationHandler { + for task in tasks { await task.value } + try Task.checkCancellation() + guard started, generation == identity else { throw CancellationError() } + initialPlatformTasks = [] + } onCancel: { + Task { @MainActor [weak self] in + guard let self, self.generation == identity else { return } + self.stop() + } + } } @discardableResult func stop() -> Bool { guard started || stopPending else { return true } + platformRequestIngress.close(generation) + for task in initialPlatformTasks { task.cancel() } + initialPlatformTasks = [] platform.disconnect() started = false activeRuntime = nil @@ -180,8 +245,9 @@ private let platformRequest: PlatformRequestCallback = { data, length in /// continuation instead of waiting forever. func deliverPlatformRequest(_ bytes: Data) async { guard started else { return } + let identity = generation let response = await platform.request(bytes) - guard started else { return } // This runtime may have stopped while awaiting the platform. + guard started, generation == identity, !Task.isCancelled else { return } guard let response else { let accepted = bytes.withUnsafeBytes { buffer in journalOCamlPlatformFailure( diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index 7299afe3..5cc4d1b9 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -15,6 +15,7 @@ struct JournalRuntimeHost: View { let payload: Data let extensions: LUIAppleExtensionRegistry @State private var runtime: JournalRuntime? + @State private var startingRuntime: JournalRuntime? @State private var fallbackClock = JournalStartupClock() @Environment(\.journalStartupClock) private var inheritedClock @State private var failure: Error? @@ -32,8 +33,8 @@ struct JournalRuntimeHost: View { "Journal could not start (runtime.start.failed). Retry opening Journal.") } actions: { Button("Retry") { - guard runtime?.stop() != false else { return } - runtime = nil; failure = nil; clock.restart(); retry += 1 + guard runtime?.stop() != false, startingRuntime?.stop() != false else { return } + runtime = nil; startingRuntime = nil; failure = nil; clock.restart(); retry += 1 } .accessibilityIdentifier("journal-runtime-retry") } @@ -62,14 +63,36 @@ struct JournalRuntimeHost: View { .environment(\.journalStartupClock, clock) .background(JournalEnvironmentObserver { platform.pushEnvironment($0) }) .task(id: retry) { - if runtime == nil { + if runtime == nil && startingRuntime == nil { do { let next = try JournalRuntime( platform: platform, startupPayload: payload, extensionRegistry: extensions) - try next.start() - runtime = next + startingRuntime = next + do { + try next.start() + try await next.waitForInitialPlatformRequests() + try Task.checkCancellation() + guard startingRuntime === next else { throw CancellationError() } + runtime = next + startingRuntime = nil + } catch { + if next.stop() { + if startingRuntime === next { + startingRuntime = nil + if Task.isCancelled { + if let stoppedFailure = next.failure { failure = stoppedFailure } + else { retry += 1 } + } + } + } else if startingRuntime === next { + failure = next.failure + } + throw error + } + } catch is CancellationError { + // Cancellation retires only its own startup; a visible host retries. } catch { failure = error Logger(subsystem: "com.logseq.journal", category: "runtime") @@ -77,7 +100,10 @@ struct JournalRuntimeHost: View { } } } - .onDisappear { runtime?.stop() } + .onDisappear { + runtime?.stop() + if startingRuntime?.stop() == false { failure = startingRuntime?.failure } + } } } diff --git a/tool/test_journal_runtime_startup.py b/tool/test_journal_runtime_startup.py new file mode 100644 index 00000000..e4054024 --- /dev/null +++ b/tool/test_journal_runtime_startup.py @@ -0,0 +1,31 @@ +"""Test production Runtime at Swift/native admission boundary. + +Swift actor scheduling, callback ownership and host publication are not owned by +the OCaml pure reducer; the capability emits requests synchronously from native +start and holds their async completion. No graph, Keychain or network is used. +Use --baseline only on pre-barrier source to reproduce immediate publication. +""" +from pathlib import Path +import subprocess +import sys +import tempfile +ROOT=Path(__file__).resolve().parents[1] +B=ROOT/'apple-tests/platform-services' +workspace=tempfile.TemporaryDirectory(prefix='journal-runtime-startup-') +W=Path(workspace.name) +source=ROOT/'swift/JournalRuntime.swift' +for name,target in [('Backend.swift','JournalRuntimeStartupBackend.swift'),('bridge.c','JournalRuntimeStartupBridge.c'),('Tests.swift','JournalRuntimeStartupTests.swift')]: + (W/name).write_text((B/target).read_text()) +if '--baseline' in sys.argv: + with (W/'Tests.swift').open('a') as out:out.write('\nextension JournalRuntime {func waitForInitialPlatformRequests() async throws {}}\n') +def run(c):subprocess.run(c,cwd=W,check=True) +flags=['-swift-version','6','-module-cache-path',str(W/'modules')] +run(['xcrun','swiftc',*flags,'-emit-module','-emit-library','-module-name','LUIAppleBackend',str(W/'Backend.swift'),'-o',str(W/'libLUIAppleBackend.dylib')]) +run(['xcrun','clang','-c',str(W/'bridge.c'),'-o',str(W/'bridge.o')]) +run(['xcrun','swiftc',*flags,'-Xfrontend','-disable-sandbox','-I',str(W),'-L',str(W),'-lLUIAppleBackend','-Xlinker','-rpath','-Xlinker',str(W),str(source.resolve()),str(W/'Tests.swift'),str(W/'bridge.o'),'-o',str(W/'tests')]) +rows=[] +for scenario in ['normal','worker','multiple','repeated-start','rejected','patch-failure','stopped','cancelled','stale-cancel','nil-response']: + r=subprocess.run([str(W/'tests'),scenario],capture_output=True,text=True,timeout=20) + rows.append(r.returncode);print(scenario,r.returncode,r.stdout.strip(),r.stderr.splitlines()[:1],flush=True) + +sys.exit(any(rows))