|
| 1 | +# Chapter 11: Dual-Mode Execution Parity |
| 2 | + |
| 3 | +## What This Chapter Is |
| 4 | + |
| 5 | +ARO programs can run in two modes: interpreted (`aro run`) and compiled (`aro build`). In theory, they should produce identical results. In practice, they share most infrastructure but diverge in subtle ways that are hard to detect until tests fail silently. |
| 6 | + |
| 7 | +This chapter documents the sources of divergence, the systematic fixes applied, and the architectural patterns that prevent future drift. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## The Divergence Problem |
| 12 | + |
| 13 | +The interpreter and binary paths share `ActionRegistry.shared`, `RuntimeContext`, and `EventBus.shared`. But two key subsystems have entirely separate implementations: |
| 14 | + |
| 15 | +**Event dispatch**: The interpreter uses Swift typed events (`FileCreatedEvent`, `StateTransitionEvent`, etc.) routed through `EventBus.subscribe(to:)`. The compiled binary uses `DomainEvent` (an event type string plus a `[String: any Sendable]` payload dictionary) routed through `aro_runtime_register_handler`. |
| 16 | + |
| 17 | +**Expression evaluation**: The interpreter evaluates expressions in `ExpressionEvaluator.swift` (Swift). The compiled binary evaluates them in `evaluateBinaryOp()` in `RuntimeBridge.swift` (also Swift, but a separate implementation with different behavior for edge cases). |
| 18 | + |
| 19 | +This separation is necessary — the compiled binary cannot execute arbitrary Swift closures — but it creates a gap that widens every time a new feature is added to only one path. |
| 20 | + |
| 21 | +--- |
| 22 | + |
| 23 | +## Source of Divergence 1: Verb Sets |
| 24 | + |
| 25 | +### The Problem |
| 26 | + |
| 27 | +`FeatureSetExecutor.executeAROStatement()` classifies verbs into sets to decide whether a statement needs execution or can be skipped. These sets were defined inline as local `let` declarations: |
| 28 | + |
| 29 | +```swift |
| 30 | +// Before — local to FeatureSetExecutor (lines 304–317) |
| 31 | +let testVerbs: Set<String> = ["then", "assert"] |
| 32 | +let requestVerbs: Set<String> = ["call", "invoke"] |
| 33 | +let updateVerbs: Set<String> = ["update", "modify", "change", "set"] |
| 34 | +// ... 7 more sets |
| 35 | +``` |
| 36 | + |
| 37 | +Because these were local, any code that needed to classify verbs elsewhere had to duplicate the sets or remain inconsistent. |
| 38 | + |
| 39 | +### The Fix |
| 40 | + |
| 41 | +`Sources/ARORuntime/Core/VerbSets.swift` extracts the sets into a shared public enum: |
| 42 | + |
| 43 | +```swift |
| 44 | +public enum VerbSets { |
| 45 | + public static let testVerbs: Set<String> = ["then", "assert"] |
| 46 | + public static let requestVerbs: Set<String> = ["call", "invoke"] |
| 47 | + public static let updateVerbs: Set<String> = ["update", "modify", "change", "set"] |
| 48 | + public static let createVerbs: Set<String> = ["create", "make", "build", "construct"] |
| 49 | + public static let mergeVerbs: Set<String> = ["merge", "combine", "join", "concat"] |
| 50 | + public static let computeVerbs: Set<String> = ["compute", "calculate", "derive"] |
| 51 | + public static let extractVerbs: Set<String> = ["extract", "parse", "get"] |
| 52 | + public static let queryVerbs: Set<String> = ["filter", "map", "reduce", "aggregate", "split"] |
| 53 | + public static let responseVerbs: Set<String> = ["write", "read", "store", "save", "persist", |
| 54 | + "log", "print", "send", "emit", "notify", |
| 55 | + "alert", "signal", "broadcast"] |
| 56 | + public static let serverVerbs: Set<String> = ["start", "stop", "restart", "keepalive", |
| 57 | + "schedule", "stream", "subscribe", |
| 58 | + "sleep", "delay", "pause"] |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +`FeatureSetExecutor` now references these via `VerbSets.testVerbs` etc. Any future code that classifies verbs has a single authoritative source. |
| 63 | + |
| 64 | +--- |
| 65 | + |
| 66 | +## Source of Divergence 2: Integer Division |
| 67 | + |
| 68 | +### The Problem |
| 69 | + |
| 70 | +Integer division produced different results in the two modes. |
| 71 | + |
| 72 | +**Interpreter** (`ExpressionEvaluator.swift`, `.divide` case): |
| 73 | + |
| 74 | +```swift |
| 75 | +// Before fix — always returned Double via numericOperation |
| 76 | +case .divide: |
| 77 | + return try numericOperation(left, right) { $0 / $1 } |
| 78 | +``` |
| 79 | + |
| 80 | +This meant `7 / 2` evaluated to `3.5` in interpreter mode. |
| 81 | + |
| 82 | +**Binary** (`RuntimeBridge.swift`, `evaluateBinaryOp()`): |
| 83 | + |
| 84 | +```swift |
| 85 | +case "/": |
| 86 | + if let li = left as? Int, let ri = right as? Int { |
| 87 | + guard ri != 0 else { return 0 } |
| 88 | + return li / ri // Integer floor division → 3 |
| 89 | + } |
| 90 | + // ... fallback to double |
| 91 | +``` |
| 92 | + |
| 93 | +### The Fix |
| 94 | + |
| 95 | +The interpreter now matches the binary behavior: Int/Int returns Int. |
| 96 | + |
| 97 | +```swift |
| 98 | +case .divide: |
| 99 | + // Int/Int → integer floor division (matches binary mode evaluateBinaryOp behavior) |
| 100 | + if let li = left as? Int, let ri = right as? Int { |
| 101 | + guard ri != 0 else { return 0 } |
| 102 | + return li / ri |
| 103 | + } |
| 104 | + return try numericOperation(left, right) { $0 / $1 } |
| 105 | +``` |
| 106 | + |
| 107 | +This is a behavioral change: ARO integer division now truncates toward zero, consistent with most languages. |
| 108 | + |
| 109 | +--- |
| 110 | + |
| 111 | +## Source of Divergence 3: Event Handler Registration |
| 112 | + |
| 113 | +### The Architecture |
| 114 | + |
| 115 | +The interpreter registers event handlers during program startup by subscribing Swift closures to typed events: |
| 116 | + |
| 117 | +```swift |
| 118 | +// Interpreter — ExecutionEngine.registerNotificationEventHandlers |
| 119 | +eventBus.subscribe(to: NotificationSentEvent.self) { event in |
| 120 | + // evaluate when condition, then execute feature set |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +The compiled binary cannot use Swift closures at the C ABI boundary. Instead, `LLVMCodeGenerator` emits calls to C-callable registration functions at program startup, passing a function pointer to the compiled feature set: |
| 125 | + |
| 126 | +``` |
| 127 | +// Generated LLVM IR (pseudocode) |
| 128 | +call void @aro_runtime_register_notification_handler( |
| 129 | + runtime_ptr, |
| 130 | + handler_func_ptr, |
| 131 | + when_condition_json_ptr |
| 132 | +) |
| 133 | +``` |
| 134 | + |
| 135 | +### The DomainEvent Co-Publishing Pattern |
| 136 | + |
| 137 | +For the binary path to receive events, every action that fires a typed event must also publish a `DomainEvent` to `EventBus.shared`. The `registerCompiledHandler` function in `RuntimeBridge.swift` subscribes to these `DomainEvents` and calls the compiled handler function. |
| 138 | + |
| 139 | +**Pattern** (applies to all event-generating actions): |
| 140 | + |
| 141 | +```swift |
| 142 | +// 1. Publish typed event for interpreter handlers |
| 143 | +if let eventBus = context.eventBus { |
| 144 | + await eventBus.publishAndTrack(MyTypedEvent(/* ... */)) |
| 145 | +} else { |
| 146 | + context.emit(MyTypedEvent(/* ... */)) |
| 147 | +} |
| 148 | + |
| 149 | +// 2. Co-publish DomainEvent for binary mode handlers |
| 150 | +// DomainEvent eventType: "MyEventType" |
| 151 | +// DomainEvent payload: { "key1": Value, "key2": Value, ... } |
| 152 | +EventBus.shared.publish(DomainEvent(eventType: "MyEventType", payload: [ |
| 153 | + "key1": value1, |
| 154 | + "key2": value2 |
| 155 | +])) |
| 156 | +``` |
| 157 | + |
| 158 | +### Payload Schemas |
| 159 | + |
| 160 | +Each event type has a defined payload schema. These are documented in comments at each callsite: |
| 161 | + |
| 162 | +| Event Type | Payload Keys | |
| 163 | +|------------|--------------| |
| 164 | +| `StateTransition` | `fromState: String`, `toState: String`, `fieldName: String`, `objectName: String`, `entityId: String?` | |
| 165 | +| `NotificationSent` | `message: String`, `target: String`, `user: targetObj`, `[targetName]: targetObj`, plus all target object fields spread at top level | |
| 166 | +| `file.created` / `file.modified` / `file.deleted` | `path: String` | |
| 167 | +| `websocket.connected` | `connectionId: String`, `path: String`, `remoteAddress: String` | |
| 168 | +| `websocket.disconnected` | `connectionId: String`, `reason: String` | |
| 169 | +| `websocket.message` | `connectionId: String`, `message: String` | |
| 170 | +| `socket.connected` | `connection: { id: String, remoteAddress: String }` | |
| 171 | +| `socket.data` | `packet: { message: String, buffer: String, data: String, connection: String }` | |
| 172 | +| `socket.disconnected` | `event: { connectionId: String, reason: String }` | |
| 173 | +| `KeyPress` | `key: String` | |
| 174 | + |
| 175 | +--- |
| 176 | + |
| 177 | +## The Handler Registration Pattern |
| 178 | + |
| 179 | +Every new event type requires a corresponding `aro_runtime_register_*` function. All follow this template in `RuntimeBridge.swift`: |
| 180 | + |
| 181 | +```swift |
| 182 | +@_cdecl("aro_runtime_register_my_event_handler") |
| 183 | +public func aro_runtime_register_my_event_handler( |
| 184 | + _ runtimePtr: UnsafeMutableRawPointer?, // runtime handle |
| 185 | + _ guardParamPtr: UnsafePointer<CChar>?, // optional guard (nullable) |
| 186 | + _ handlerFuncPtr: UnsafeMutableRawPointer? // compiled function pointer |
| 187 | +) { |
| 188 | + guard let runtimePtr, let handlerFuncPtr else { return } |
| 189 | + let runtimeHandle = Unmanaged<AROCRuntimeHandle>.fromOpaque(runtimePtr).takeUnretainedValue() |
| 190 | + let handlerAddress = Int(bitPattern: handlerFuncPtr) |
| 191 | + |
| 192 | + runtimeHandle.runtime.registerCompiledHandler( |
| 193 | + eventType: "MyEventType", |
| 194 | + handlerName: "My Handler" |
| 195 | + ) { @Sendable event in |
| 196 | + // 1. Evaluate guard condition (if any) |
| 197 | + // 2. Run handler on pthread (NOT GCD — avoids 64-thread limit) |
| 198 | + await withCheckedContinuation { continuation in |
| 199 | + Thread { |
| 200 | + let contextHandle = AROCContextHandle(runtime: runtimeHandle, |
| 201 | + featureSetName: "My Handler") |
| 202 | + // Bind event payload to context |
| 203 | + contextHandle.context.bind("event", value: event.payload) |
| 204 | + for (k, v) in event.payload { |
| 205 | + contextHandle.context.bind("event:\(k)", value: v) |
| 206 | + } |
| 207 | + // Execute compiled handler |
| 208 | + let handlerFunc = unsafeBitCast(/* reconstructed ptr */, |
| 209 | + to: HandlerFunc.self) |
| 210 | + let result = handlerFunc(contextPtr) |
| 211 | + // Cleanup |
| 212 | + continuation.resume() |
| 213 | + }.start() |
| 214 | + } |
| 215 | + } |
| 216 | +} |
| 217 | +``` |
| 218 | + |
| 219 | +**Why pthreads, not GCD?** GCD's cooperative thread pool has a 64-thread limit. During intensive event processing (many events firing handlers concurrently), GCD deadlocks when all 64 threads are blocked waiting for continuation resumes. Foundation `Thread` bypasses this limit. The `CompiledExecutionPool.shared` semaphore prevents unbounded thread creation. |
| 220 | + |
| 221 | +The three-step pattern for every new event handler: |
| 222 | + |
| 223 | +1. **`LLVMExternalDeclEmitter.swift`**: Declare the C function with LLVM types |
| 224 | +2. **`LLVMCodeGenerator.registerEventHandlers`**: Detect the business activity pattern and emit the registration call |
| 225 | +3. **`RuntimeBridge.swift`**: Implement the `@_cdecl` function |
| 226 | + |
| 227 | +--- |
| 228 | + |
| 229 | +## The `when` Guard: Interpreter vs Binary |
| 230 | + |
| 231 | +Handler feature sets can have a `when` guard: |
| 232 | + |
| 233 | +```aro |
| 234 | +(Greet User: NotificationSent Handler) when <age> >= 16 { |
| 235 | + (* ... *) |
| 236 | +} |
| 237 | +``` |
| 238 | + |
| 239 | +**Interpreter**: `ExecutionEngine` evaluates this expression inline using `ExpressionEvaluator` with the target object's fields bound to context. |
| 240 | + |
| 241 | +**Binary**: `LLVMCodeGenerator` serializes the `whenCondition` AST node to JSON using `serializeExpression()`: |
| 242 | + |
| 243 | +```json |
| 244 | +{"$binary":{"op":">=","left":{"$var":"age"},"right":{"$literal":16}}} |
| 245 | +``` |
| 246 | + |
| 247 | +This JSON is passed as a string constant to the registration function. At runtime, `evaluateExpressionJSON()` in `RuntimeBridge.swift` deserializes and evaluates it against a `RuntimeContext` populated with the event payload. |
| 248 | + |
| 249 | +This means the binary `when` guard evaluates against a flat payload dictionary, so the payload must spread the target object's fields at top level. |
| 250 | + |
| 251 | +--- |
| 252 | + |
| 253 | +## Test Coverage: The mode: both Directive |
| 254 | + |
| 255 | +Every `test.hint` file has a `mode` field: |
| 256 | + |
| 257 | +| Value | Meaning | |
| 258 | +|-------|---------| |
| 259 | +| `both` | Run in interpreter and compiled binary modes, compare output | |
| 260 | +| `interpreter` | Run interpreter only (binary mode unsupported) | |
| 261 | + |
| 262 | +Out of 85 examples, **81 currently run in `mode: both`** (including the default, which is `both`). The 4 interpreter-only examples have open issues: |
| 263 | + |
| 264 | +| Example | Issue | Root Cause | |
| 265 | +|---------|-------|------------| |
| 266 | +| `SocketClient` | #134 | `AROSocketClient` uses `ManagedAtomic<Bool>` → SIGSEGV in binary | |
| 267 | +| `MultiService` | #134 | Depends on SocketClient fix | |
| 268 | +| `Scoping` | #135 | `AppReady Handler` event payload structure differs in binary mode | |
| 269 | +| `EventReplay` | #136 | `EventRecorder.swift` not implemented in C bridge | |
| 270 | + |
| 271 | +The `occurrence-check: true` hint enables order-independent output comparison, which is essential for event handlers that fire asynchronously in binary mode. |
| 272 | + |
| 273 | +--- |
| 274 | + |
| 275 | +## Verification Checklist for New Event Types |
| 276 | + |
| 277 | +When adding a new action that fires events: |
| 278 | + |
| 279 | +1. **Add DomainEvent co-publish** after the typed event publish |
| 280 | +2. **Document the payload schema** with a `// DomainEvent eventType: payload:` comment |
| 281 | +3. **Add `@_cdecl` registration function** in `RuntimeBridge.swift` |
| 282 | +4. **Declare the extern** in `LLVMExternalDeclEmitter.swift` |
| 283 | +5. **Detect the business activity** in `LLVMCodeGenerator.registerEventHandlers` (before generic `hasSuffix(" Handler")`) |
| 284 | +6. **Spread guard fields** into the DomainEvent payload if the handler has a `when` condition |
| 285 | +7. **Add or update an example** with `mode: both` and `occurrence-check: true` |
| 286 | +8. **Run** `swift build -c release && ./test-examples.pl` |
| 287 | + |
| 288 | +--- |
| 289 | + |
| 290 | +## Lessons |
| 291 | + |
| 292 | +**Silent divergence is the worst kind of bug.** A binary that produces wrong results without crashing is harder to diagnose than one that crashes immediately. The `mode: both` test directive is the primary defense: any behavioral difference between interpreter and binary becomes a test failure. |
| 293 | + |
| 294 | +**Co-publishing is cheaper than unification.** A clean architectural solution would use a single event system for both modes. In practice, the typed event system is deeply integrated with the interpreter (closures, `async/await`, `publishAndTrack`), while the binary needs C-callable, pthread-compatible registration. DomainEvent co-publishing bridges the two worlds with minimal coupling and no breaking changes. |
| 295 | + |
| 296 | +**Payload schemas are contracts.** The `// DomainEvent payload:` comments are not just documentation — they define the interface between the action that fires the event and the `RuntimeBridge` function that receives it. When the payload changes, both sides must be updated atomically. |
0 commit comments