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
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"fable": {
"version": "5.20.0",
"version": "5.20.1",
"commands": [
"fable"
],
Expand Down
2 changes: 1 addition & 1 deletion examples/timeflies-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ name = "timeflies-python"
version = "0.1.0"
requires-python = ">= 3.12"
dependencies = [
"fable-library>=5.20.0",
"fable-library>=5.20.1",
"tk>=0.1.0",
]
114 changes: 57 additions & 57 deletions examples/timeflies-python/uv.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ name = "fable-actor"
version = "0.1.0"
requires-python = ">= 3.12"
dependencies = [
"fable-library>=5.20.0",
"fable-library>=5.20.1",
]
22 changes: 8 additions & 14 deletions src/Fable.Actor/Actor.fs
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,10 @@ type Actor<'Msg> internal (mb: MailboxProcessor<'Msg>, cts: System.Threading.Can
member _.Post(msg: 'Msg) = lifetime.Post(fun () -> mb.Post msg)

type ActorBuilder() =
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038
// Revisit the extra delayed binder and Lifetime.guard calls after portable Bind/Delay
// stop on cancellation. Keep actor startup/late-callback cancellation regressions.
// decision: delays portable user binders so a cancelled continuation cannot execute user code before its next token check
member _.Bind(op: Async<'T>, f: 'T -> Async<'U>) : Async<'U> =
#if FABLE_COMPILER
async.Bind(op, fun value -> Lifetime.guard (fun () -> f value))
async.Bind(op, fun value -> async.Delay(fun () -> f value))
#else
// invariant: resource-producing binders enter their finalizer before the next cancellation check
async.Bind(op, f)
Expand All @@ -178,10 +175,10 @@ type ActorBuilder() =
member _.ReturnFrom(op: Async<'T>) : Async<'T> = async.ReturnFrom(op)
member _.Zero() : Async<unit> = async.Zero()

/// Guard the user expression even on runtimes whose cancellation callback falls through into computation code.
/// Delay the user expression until its actor continuation runs.
///
/// invariant: an already cancelled actor continuation does not evaluate its delayed user expression
member _.Delay(f: unit -> Async<'T>) : Async<'T> = Lifetime.guard f
member _.Delay(f: unit -> Async<'T>) : Async<'T> = async.Delay f

member _.Combine(first: Async<unit>, second: Async<'T>) : Async<'T> =
async.Combine(first, async.Delay(fun () -> second))
Expand All @@ -190,10 +187,7 @@ type ActorBuilder() =

/// Run cleanup once and preserve its failure, including during cancellation.
///
/// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038
/// Revisit the duplicate-terminal guard after the delay fix, but retain cleanup failure
/// reporting: standard Async preserves cancellation even when its finalizer throws.
/// Actor.stop must still report that cleanup failure, including on .NET.
/// invariant: duplicate terminal callbacks from supplied Async work cannot repeat cleanup
///
/// decision: routes cleanup exceptions explicitly because standard Async finalizers can lose failures during cancellation
member _.TryFinally(body: Async<'T>, compensation: unit -> unit) : Async<'T> =
Expand Down Expand Up @@ -526,7 +520,7 @@ module Actor =
lifetime.AddResource(Lifetime.cancellation token lifetime.RequestStop)
attach inbox

let workflow = Lifetime.guard (fun () -> body inbox)
let workflow = async.Delay(fun () -> body inbox)

let start () =
Async.StartWithContinuations(
Expand Down Expand Up @@ -588,7 +582,7 @@ module Actor =
if cleanupDeadline <= 0 then
invalidArg "cleanupDeadline" "Cleanup deadline must be positive"

Lifetime.guard (fun () ->
async.Delay(fun () ->
Lifetime.withContext (fun ((ok, _, cancelled), token) ->
let pending =
Lifetime.Pending(fun result ->
Expand Down Expand Up @@ -661,7 +655,7 @@ module Actor =

/// Await a reply without a reply deadline, preserving rc.11's default. Target shutdown and caller cancellation settle the wait.
let call target msg : ActorOp<'Reply> =
Lifetime.guard (fun () ->
async.Delay(fun () ->
async {
let! result = callCore None System.Threading.CancellationToken.None target msg
return replyOrRaise result
Expand All @@ -674,7 +668,7 @@ module Actor =
if deadline <= 0 then
invalidArg "deadline" "Reply deadline must be positive"

Lifetime.guard (fun () ->
async.Delay(fun () ->
async {
let! result =
callCore (Some deadline) System.Threading.CancellationToken.None target msg
Expand Down
106 changes: 7 additions & 99 deletions src/Fable.Actor/Lifecycle.fs
Original file line number Diff line number Diff line change
Expand Up @@ -6,35 +6,18 @@ open System
open System.Threading
open Fable.Actor.Types

// TODO(upstream): retire the marked adapters only after the pinned compiler and runtime
// include the referenced fixes and the four-target suite passes without those adapters.
// decision: delegates synchronization, cancellation registration, and delay settlement to Fable's fixed runtime
// assumption: Fable compiler and runtime 5.20.1 or later include https://github.com/fable-compiler/Fable/pull/5035, https://github.com/fable-compiler/Fable/pull/5036, https://github.com/fable-compiler/Fable/pull/5037, and https://github.com/fable-compiler/Fable/pull/5038
// Pending settlement and ActorLifetime ownership remain library responsibilities.
module internal Lifetime =
#if FABLE_COMPILER_PYTHON
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5035
// Replace this Python gate branch with obj()/lock and remove Platform's gate helpers
// once the published Python runtime shares a stable lock per object.
let newGate = Platform.newGate
// decision: owns a stable RLock because fable-library 5.19 util.lock creates a fresh lock for each invocation
let synchronize gate action =
Platform.enterGate gate

try
action ()
finally
Platform.leaveGate gate
#else
let newGate () = obj ()
let synchronize gate action = lock gate action
#endif

let withContext body : Async<'T> =
#if FABLE_COMPILER_PYTHON || FABLE_COMPILER_JAVASCRIPT
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037
// and https://github.com/fable-compiler/Fable/pull/5038
// Retire the Python adapter after unit continuations and terminal cancellation are fixed.
// TODO(upstream): native JS TryFinally also checks cancellation before installing
// compensation. Retire this adapter only when cancelled resource acquisition disposes.
// TODO(upstream): native JS/Python TryFinally checks cancellation before installing
// compensation. Retire this adapter only when cancelled acquisition still disposes
// and actor cleanup failures remain observable.
Platform.fromContext body
#else
async {
Expand Down Expand Up @@ -89,43 +72,6 @@ module internal Lifetime =
)
#endif

let guard (expression: unit -> Async<'T>) : Async<'T> =
#if FABLE_COMPILER_PYTHON
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038
// Collapse this Python guard to async.Delay after cancelled Delay/Bind no longer
// run user work or receive a second terminal callback from a cancelled delay.
withContextRun (fun ((ok, error, cancelled), token) run ->
let gate = newGate ()
let mutable finished = false

let finish next value =
let run =
synchronize gate (fun () ->
if finished then
false
else
finished <- true
true)

if run then
next value

if token.IsCancellationRequested then
finish cancelled (OperationCanceledException())
else
let operation =
try
Choice1Of2(expression ())
with ex ->
Choice2Of2 ex

match operation with
| Choice2Of2 ex -> finish error ex
| Choice1Of2 op -> run op (finish ok, finish error, finish cancelled))
#else
async.Delay expression
#endif

let disposable f = {
new IDisposable with
member _.Dispose() = f ()
Expand Down Expand Up @@ -177,54 +123,16 @@ module internal Lifetime =
notify |> Option.iter (fun f -> f value)

let cancellation (token: CancellationToken) callback =
#if FABLE_COMPILER_PYTHON
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5036
// Use token.Register like the other targets once it returns a disposable handle
// and safely handles disposal during cancellation; delete the listener-field adapters.
let id = Platform.addCancellationListener token callback

let registration =
disposable (fun () -> synchronize (Platform.cancellationGate token) (fun () -> Platform.removeCancellationListener token id))
#else
let registration = token.Register(Action callback) :> IDisposable
#endif
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5036
// Remove the post-registration inspection once published JS/Python Register invokes
// already-cancelled callbacks immediately; keep the pending operation's settlement gate.
// Fable JS/Python Register does not invoke an already-cancelled token's callback.
// invariant: registration followed by inspection covers cancellation before and during acquisition
if token.IsCancellationRequested then
callback ()

registration
token.Register(Action callback) :> IDisposable

let deadline (ms: int) callback =
#if FABLE_COMPILER_PYTHON
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038
// Reuse the CTS/Async.Sleep branch once cancellation retires the Python timer
// and listener exactly once; remove Platform.startDeadline/cancelDeadline with it.
let handle = Platform.startDeadline ms callback
disposable (fun () -> Platform.cancelDeadline handle)
#else
let cts = new CancellationTokenSource()

Async.StartWithContinuations(
async { do! Async.Sleep ms },
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037
// This explicit value parameter avoids unit callbacks whose captured default
// gets overwritten by None in Python; simplify only after generated-code validation.
(fun value ->
ignore value
callback ()),
(fun _ -> ()),
(fun _ -> ()),
cts.Token
)
Async.StartWithContinuations(async { do! Async.Sleep ms }, (fun () -> callback ()), (fun _ -> ()), (fun _ -> ()), cts.Token)

disposable (fun () ->
cts.Cancel()
cts.Dispose())
#endif

/// Internal state shared by handles for one emulated actor generation.
///
Expand Down
63 changes: 8 additions & 55 deletions src/Fable.Actor/Platform.fs
Original file line number Diff line number Diff line change
Expand Up @@ -170,48 +170,6 @@ let stopProcess (pid: Pid<'Msg>) (deadline: int) : bool =

#endif

#if FABLE_COMPILER_PYTHON

open Fable.Core

// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5036
// Replace addCancellationListener/removeCancellationListener/cancellationGate with the
// public disposable token.Register API when Lifetime.cancellation retires its Python branch.
// decision: uses native registration handles because fable-library 5.19 Register omits its return value
// decision: replaces the listener dictionary on removal so disposal during Cancel does not mutate its live iterator
// invariant: removing an actor registration leaves other token listeners installed
[<Emit("$0.add_listener($1)")>]
let addCancellationListener (token: System.Threading.CancellationToken) (callback: unit -> unit) : int = nativeOnly

[<Emit("setattr($0, 'listeners', {k: v for k, v in $0.listeners.items() if k != $1})")>]
let removeCancellationListener (token: System.Threading.CancellationToken) (id: int) : unit = nativeOnly

// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5035
// Delete newGate/enterGate/leaveGate when Lifetime.synchronize uses the fixed runtime lock.
[<Emit("__import__('threading').RLock()")>]
let newGate () : obj = nativeOnly

[<Emit("$0.acquire()")>]
let enterGate (gate: obj) : unit = nativeOnly

[<Emit("$0.release()")>]
let leaveGate (gate: obj) : unit = nativeOnly

[<Emit("$0.lock")>]
let cancellationGate (token: System.Threading.CancellationToken) : obj = nativeOnly

// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038
// Delete startDeadline/cancelDeadline after Lifetime.deadline uses fixed Async.Sleep;
// retain positive deadlines and disposal of the actor-owned timer on every settlement.
// decision: retains the asyncio timer handle so settlement cancels the timer rather than only suppressing its callback
[<Emit("__import__('asyncio').get_running_loop().call_later($0 / 1000, $1)")>]
let startDeadline (ms: int) (callback: unit -> unit) : obj = nativeOnly

[<Emit("$0.cancel()")>]
let cancelDeadline (handle: obj) : unit = nativeOnly

#endif

#if FABLE_COMPILER_BEAM

let exitReason (reason: obj) : ActorExit =
Expand Down Expand Up @@ -264,15 +222,13 @@ open Fable.Core

/// Build a context-aware Async without a preceding cancellation checkpoint.
///
/// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037
/// and https://github.com/fable-compiler/Fable/pull/5038
/// Delete the Python branch when Lifetime.withContext uses standard Async on Python.
/// TODO(upstream): retire the JS branch once native TryFinally installs compensation
/// before checking cancellation; the resource-acquisition regressions must still pass.
/// TODO(upstream): retire the JS/Python branches only when native TryFinally installs
/// compensation before its cancellation checkpoint and actor cleanup failures remain observable.
///
/// decision: hands cancellation to the actor settlement gate so acquired resources receive cleanup even when their token is already cancelled
#if FABLE_COMPILER_PYTHON
[<Fable.Core.Emit("lambda ctx: $0(((ctx.on_success, ctx.on_error, ctx.on_cancel), ctx.cancel_token))")>]
// invariant: an erased F# unit success supplies None to the Python context callback
[<Fable.Core.Emit("lambda ctx: $0(((lambda value=None: ctx.on_success(value), ctx.on_error, ctx.on_cancel), ctx.cancel_token))")>]
#else
[<Fable.Core.Emit("ctx => $0([[ctx.onSuccess, ctx.onError, ctx.onCancel], ctx.cancelToken])")>]
#endif
Expand All @@ -287,17 +243,14 @@ let fromContext

/// Run a nested Async using the incoming trampoline and guarded continuations.
///
/// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5038
/// Delete the Python branch with the guard/finalizer adapters once native Delay/Bind
/// and TryFinally preserve cancellation and exactly-once cleanup, and Receive uses
/// the native mailbox workflow without a separate async root.
/// TODO(upstream): retire the JS branch when native TryFinally installs cleanup before
/// cancellation and Receive honors owned cancellation without its current wrapper.
/// TODO(upstream): retire these context adapters when native mailbox cancellation and
/// finalizers meet the actor contract without creating a separate Async trampoline.
///
/// decision: preserves the incoming trampoline so immediately completed actor steps still yield before exhausting the stack
/// invariant: terminal continuation chains also yield through the shared trampoline
#if FABLE_COMPILER_PYTHON
[<Fable.Core.Emit("lambda ctx: $0(((ctx.on_success, ctx.on_error, ctx.on_cancel), ctx.cancel_token), lambda op: lambda cs: op(ctx.__class__(ctx.trampoline, ctx.cancel_token, *(lambda value, next=next: ctx.trampoline.run(lambda: next(value)) for next in cs))))")>]
// invariant: an erased F# unit success supplies None to the Python context callback
[<Fable.Core.Emit("lambda ctx: $0(((lambda value=None: ctx.on_success(value), ctx.on_error, ctx.on_cancel), ctx.cancel_token), lambda op: lambda cs: op(ctx.__class__(ctx.trampoline, ctx.cancel_token, *(lambda value=None, next=next: ctx.trampoline.run(lambda: next(value)) for next in cs))))")>]
#else
[<Fable.Core.Emit("ctx => $0([[ctx.onSuccess, ctx.onError, ctx.onCancel], ctx.cancelToken], op => cs => { const callbacks = cs.map(next => value => { const resume = () => next(value); if (ctx.trampoline.incrementAndCheck()) ctx.trampoline.hijack(resume); else resume(); }); op({...ctx, onSuccess: callbacks[0], onError: callbacks[1], onCancel: callbacks[2]}); })")>]
#endif
Expand Down
5 changes: 1 addition & 4 deletions test/Helpers.fs
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,7 @@ module Helpers =
Lifetime.Pending(fun result ->
match result with
| Some ex -> error ex
// TODO(upstream): https://github.com/fable-compiler/Fable/pull/5037
// Replace the dummy int with unit once Python unit continuations
// accept ok (); retain this independent observed-exit barrier.
| None -> ok 1)
| None -> ok ())

pending.Add(
Lifetime.deadline 2000 (fun () -> pending.Settle(Some(System.TimeoutException "Worker exit was not observed")))
Expand Down
Loading
Loading