Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions apple-tests/platform-services/JournalRuntimeStartupBackend.swift
Original file line number Diff line number Diff line change
@@ -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]
}
}
22 changes: 22 additions & 0 deletions apple-tests/platform-services/JournalRuntimeStartupBridge.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#include <stdint.h>
#include <pthread.h>
#include <string.h>
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;}
102 changes: 102 additions & 0 deletions apple-tests/platform-services/JournalRuntimeStartupTests.swift
Original file line number Diff line number Diff line change
@@ -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<Data?, Never>] = []
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)")
}
}
82 changes: 74 additions & 8 deletions swift/JournalRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Void, Never>] = []
private var stopPending = false

init(
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
38 changes: 32 additions & 6 deletions swift/JournalRuntimeHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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")
}
Expand Down Expand Up @@ -62,22 +63,47 @@ 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")
.error("Unable to start journal runtime (runtime.start.failed)")
}
}
}
.onDisappear { runtime?.stop() }
.onDisappear {
runtime?.stop()
if startingRuntime?.stop() == false { failure = startingRuntime?.failure }
}
}
}

Expand Down
31 changes: 31 additions & 0 deletions tool/test_journal_runtime_startup.py
Original file line number Diff line number Diff line change
@@ -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))
Loading