future_form_ffi 0.1.0

FFI support for future_form: host-driven polling, opaque handles, and effect slots
Documentation
#!/usr/bin/env python3
"""End-to-end demonstration: Python host drives a Rust async state machine
that requests effects (timestamps, logging) from the host.

This is the "full" sans-IO pattern: the host inspects pending effects,
fulfills them, then re-polls.

Build the Rust cdylib first:
    cargo build --manifest-path ../effects_bridge/Cargo.toml
Then:
    python3 effects_host.py
"""

import ctypes
import platform
import sys
import time
from ctypes import c_uint8, c_uint64, c_void_p, POINTER
from pathlib import Path


# -- Load the Rust cdylib ----------------------------------------------------

def _find_lib():
    bridge_dir = Path(__file__).resolve().parent.parent / "effects_bridge" / "target" / "debug"
    if platform.system() == "Darwin":
        return bridge_dir / "libeffects_bridge.dylib"
    return bridge_dir / "libeffects_bridge.so"


lib = ctypes.CDLL(str(_find_lib()))


# -- EffectPollResult layout --------------------------------------------------

class EffectPollResult(ctypes.Structure):
    _fields_ = [
        ("status", c_uint8),
        ("_padding", c_uint8 * 7),
        ("value_a", c_uint64),
        ("value_b", c_uint64),
    ]


# -- Effect tags --------------------------------------------------------------

EFFECT_NONE = 0
EFFECT_GET_TIMESTAMP = 1
EFFECT_LOG = 2

# -- Declare function signatures ----------------------------------------------

lib.effects_counter_new.argtypes = [c_uint64]
lib.effects_counter_new.restype = c_void_p

lib.effects_counter_free.argtypes = [c_void_p]
lib.effects_counter_free.restype = None

lib.counter_value_at_now.argtypes = [c_void_p]
lib.counter_value_at_now.restype = c_void_p

lib.counter_add_with_log.argtypes = [c_void_p, c_uint64]
lib.counter_add_with_log.restype = c_void_p

lib.effects_future_poll.argtypes = [c_void_p]
lib.effects_future_poll.restype = EffectPollResult

lib.pending_effect.argtypes = [c_void_p]
lib.pending_effect.restype = c_uint8

lib.pending_log_message.argtypes = [c_void_p]
lib.pending_log_message.restype = POINTER(c_uint8)

lib.pending_log_message_len.argtypes = [c_void_p]
lib.pending_log_message_len.restype = c_uint64

lib.fulfill_timestamp.argtypes = [c_void_p, c_uint64]
lib.fulfill_timestamp.restype = None

lib.fulfill_log.argtypes = [c_void_p]
lib.fulfill_log.restype = None

lib.effects_future_free.argtypes = [c_void_p]
lib.effects_future_free.restype = None


# -- Sans-IO polling loop with effect dispatch --------------------------------

def poll_with_effects(future_handle):
    """Drive a future to completion, fulfilling effects as they arise."""
    while True:
        result = lib.effects_future_poll(future_handle)
        if result.status != 0:
            return result.value_a, result.value_b

        # The future is pending — find out what it needs.
        effect = lib.pending_effect(future_handle)

        if effect == EFFECT_GET_TIMESTAMP:
            # Host provides the current timestamp.
            now_ms = int(time.time() * 1000)
            lib.fulfill_timestamp(future_handle, now_ms)

        elif effect == EFFECT_LOG:
            # Host reads and prints the log message.
            ptr = lib.pending_log_message(future_handle)
            length = lib.pending_log_message_len(future_handle)
            msg = ctypes.string_at(ptr, length).decode("utf-8")
            print(f"  [LOG]  {msg}")
            lib.fulfill_log(future_handle)

        else:
            print(f"  [WARN] unknown effect {effect}, re-polling")


# -- Test harness -------------------------------------------------------------

passed = 0
failed = 0


def check(name, got, want):
    global passed, failed
    if got == want:
        print(f"  PASS  {name}: got {got}")
        passed += 1
    else:
        print(f"  FAIL  {name}: got {got}, want {want}")
        failed += 1


def main():
    global passed, failed

    print("=== Python <-> Rust FFI (effects) e2e test ===")
    print()

    counter = lib.effects_counter_new(10)

    # --- Test 1: value_at_now (value=10, timestamp>0) ---
    fut = lib.counter_value_at_now(counter)
    val, ts = poll_with_effects(fut)
    lib.effects_future_free(fut)
    check("value_at_now value", val, 10)
    if ts > 0:
        print(f"  PASS  value_at_now timestamp: {ts} ms")
        passed += 1
    else:
        print(f"  FAIL  value_at_now timestamp: got {ts}, want > 0")
        failed += 1

    # --- Test 2: add_with_log (10 + 32 = 42, should print log) ---
    fut = lib.counter_add_with_log(counter, 32)
    val, _ = poll_with_effects(fut)
    lib.effects_future_free(fut)
    check("add_with_log(10, 32)", val, 42)

    # --- Test 3: different counter ---
    lib.effects_counter_free(counter)
    counter = lib.effects_counter_new(100)

    fut = lib.counter_value_at_now(counter)
    val, ts = poll_with_effects(fut)
    lib.effects_future_free(fut)
    check("value_at_now(100) value", val, 100)
    if ts > 0:
        print(f"  PASS  value_at_now(100) timestamp: {ts} ms")
        passed += 1
    else:
        print(f"  FAIL  value_at_now(100) timestamp: got {ts}, want > 0")
        failed += 1

    fut = lib.counter_add_with_log(counter, 900)
    val, _ = poll_with_effects(fut)
    lib.effects_future_free(fut)
    check("add_with_log(100, 900)", val, 1000)

    lib.effects_counter_free(counter)

    # --- Summary ---
    print()
    print(f"--- {passed} passed, {failed} failed ---")

    if failed > 0:
        sys.exit(1)


if __name__ == "__main__":
    main()