From 2705721bdaa2f5e7e16ddbd2c002ab7ccc562f90 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 4 Mar 2026 00:09:19 +0100 Subject: [PATCH 1/4] [Python] Fix exception variable captured in deferred closures (NameError) Python's `except E as name:` implicitly deletes `name` at the end of the except block (per PEP 3110). This caused a NameError at runtime when a closure capturing the exception variable was stored and called after the except block exited. Fix: in `makeHandler`, copy the exception variable to `name_` (a regular local that Python won't delete) immediately inside the except block, then rename all body references from `name` to `name_` via `FableTransforms.replaceValues`. The original name is kept in the `except ... as name:` clause for readability. Affects both direct deferred closures (`fun () -> ex.Message`) and lambda-with-argument patterns (`fun obv _ -> obv.OnErrorAsync ex`) as seen in AsyncRx's `defer` function. Adds two regression tests to TestMisc.fs covering both patterns. Co-Authored-By: Claude Sonnet 4.6 --- .../Python/Fable2Python.Transforms.fs | 31 +++++++++++++++---- src/quicktest-py/quicktest.fsx | 16 ++++++++-- tests/Python/TestMisc.fs | 25 +++++++++++++++ 3 files changed, 63 insertions(+), 9 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index b4b611206b..5ef9dd128f 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -1210,9 +1210,25 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I // try .. catch statements cannot be tail call optimized let ctx = { ctx with TailCallOpportunity = None } - let makeHandler exnType handlerBody identifier = - let transformedBody = transformBlock com ctx returnStrategy handlerBody - ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = transformedBody) + let makeHandler exnType handlerBody (param: Fable.Ident) identifier = + // Python's `except E as name:` deletes `name` at the end of the except block. + // This breaks deferred closures that capture the exception variable, since the + // cell becomes unbound before the closure is invoked. + // Fix: keep the original name in the `except ... as` clause, then copy it to + // `name_` (a regular local Python won't delete) and rename all body references. + let (Identifier identName) = identifier + let safeIdentName = identName + "_" + let safeParam = { param with Name = safeIdentName } + + let renamedBody = + FableTransforms.replaceValues (Map [ param.Name, Fable.IdentExpr safeParam ]) handlerBody + + let transformedBody = transformBlock com ctx returnStrategy renamedBody + // Annotated assignment: ex_: ExceptionType = ex + let saveStmt = + Statement.assign (identAsExpr com ctx safeParam, exnType, value = Expression.name identName) + + ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = saveStmt :: transformedBody) let handlers, handlerStmts = match catch with @@ -1228,7 +1244,8 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I // No type tests found, use Exception to match F#/.NET semantics. // Users can explicitly catch KeyboardInterrupt, SystemExit, GeneratorExit // using type tests if needed. - let handler = makeHandler (Expression.identifier "Exception") catchBody identifier + let handler = + makeHandler (Expression.identifier "Exception") catchBody param identifier Some [ handler ], [] @@ -1239,7 +1256,7 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I |> List.choose (fun (typ, handlerBody) -> getExceptionTypeExpr com ctx typ |> Option.map (fun (exnTypeExpr, stmts) -> - makeHandler exnTypeExpr handlerBody identifier, stmts + makeHandler exnTypeExpr handlerBody param identifier, stmts ) ) |> List.unzip @@ -1249,7 +1266,9 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I let fallbackHandlers = match fallback with | Some fallbackExpr when not (ExceptionHandling.isReraise fallbackExpr) -> - [ makeHandler (Expression.identifier "Exception") fallbackExpr identifier ] + [ + makeHandler (Expression.identifier "Exception") fallbackExpr param identifier + ] | _ -> [] Some(handlers @ fallbackHandlers), List.concat stmts diff --git a/src/quicktest-py/quicktest.fsx b/src/quicktest-py/quicktest.fsx index d0fb0f6474..7b2ad3f0d9 100644 --- a/src/quicktest-py/quicktest.fsx +++ b/src/quicktest-py/quicktest.fsx @@ -36,14 +36,24 @@ let throwsAnyError (f: unit -> 'a) : unit = if success then printfn "[ERROR EXPECTED]" +// Reproduction: exception variable captured in a deferred closure (not immediately called) +let testExCapturedInDeferredClosure () = + let getMsg = + try + failwith "boom" + fun () -> "no error" + with ex -> + fun () -> ex.Message // closure captures ex, not immediately called + + getMsg () // called AFTER the try/with block + [] let main argv = let name = Array.tryHead argv |> Option.defaultValue "Guest" printfn $"Hello {name}!" - // Open file with builtin `open` - // use file = builtins.``open``(StringPath "data.txt") - // file.read() |> printfn "File contents: %s" + let msg = testExCapturedInDeferredClosure () + printfn $"Caught: {msg}" printfn "All tests passed!" diff --git a/tests/Python/TestMisc.fs b/tests/Python/TestMisc.fs index c4304146f3..7a90817529 100644 --- a/tests/Python/TestMisc.fs +++ b/tests/Python/TestMisc.fs @@ -1501,3 +1501,28 @@ let ``test static properties work correctly`` () = // Test read-only explicit property StaticPropertiesTestClass.ReadOnlyProperty |> equal "ReadOnlyValue" + +[] +let ``test exception variable captured in deferred zero-arg closure`` () = + // Regression: Python deletes the `except ... as name` variable at the end of the + // except block. A closure that captures it and is called *after* the block would + // get a NameError. Fix: copy to `name_` inside the block and rename references. + let getMsg = + try + failwith "boom" + fun () -> "no error" + with ex -> + fun () -> ex.Message // captured, called after block exits + getMsg () |> equal "boom" + +[] +let ``test exception variable captured in deferred single-arg closure`` () = + // Same fix, but the closure takes an argument (mirrors the AsyncRx `defer` pattern: + // `with ex -> ofAsyncWorker (fun obv _ -> obv.OnErrorAsync ex)`). + let sub = + try + failwith "boom" + (fun (_: int) -> "no error") + with ex -> + (fun (_: int) -> ex.Message) // lambda-with-arg captures ex + sub 42 |> equal "boom" From 777f3617b9d76c2d7cca9338c7ae8afe929a65bb Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 4 Mar 2026 00:10:45 +0100 Subject: [PATCH 2/4] [Python] Update CHANGELOG for exception-in-deferred-closure fix Co-Authored-By: Claude Sonnet 4.6 --- src/Fable.Cli/CHANGELOG.md | 1 + src/Fable.Compiler/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Fable.Cli/CHANGELOG.md b/src/Fable.Cli/CHANGELOG.md index 39b61402a7..1cccea7777 100644 --- a/src/Fable.Cli/CHANGELOG.md +++ b/src/Fable.Cli/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli) +* [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli) ## 5.0.0-rc.2 - 2026-03-03 diff --git a/src/Fable.Compiler/CHANGELOG.md b/src/Fable.Compiler/CHANGELOG.md index fbb481dee4..ff5feb0b41 100644 --- a/src/Fable.Compiler/CHANGELOG.md +++ b/src/Fable.Compiler/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli) +* [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli) ## 5.0.0-rc.2 - 2026-03-03 From 8c1b71685291201833662e703b47bbf14c3e4044 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 4 Mar 2026 00:29:38 +0100 Subject: [PATCH 3/4] [Python] Only save exception variable when it is actually used in handler body Use `isIdentUsed` to skip the save-to-safe-copy step when the exception variable is not referenced in the handler body. When it is used, use `getUniqueNameInDeclarationScope` to give each handler its own unique safe-copy name, preventing Pylance `reportRedeclaration` warnings when multiple typed `except` handlers exist in the same function. Co-Authored-By: Claude Sonnet 4.6 --- .../Python/Fable2Python.Transforms.fs | 36 ++++++++++--------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs index 5ef9dd128f..7f280d15ec 100644 --- a/src/Fable.Transforms/Python/Fable2Python.Transforms.fs +++ b/src/Fable.Transforms/Python/Fable2Python.Transforms.fs @@ -1211,24 +1211,28 @@ let transformTryCatch com (ctx: Context) r returnStrategy (body, catch: (Fable.I let ctx = { ctx with TailCallOpportunity = None } let makeHandler exnType handlerBody (param: Fable.Ident) identifier = - // Python's `except E as name:` deletes `name` at the end of the except block. - // This breaks deferred closures that capture the exception variable, since the - // cell becomes unbound before the closure is invoked. - // Fix: keep the original name in the `except ... as` clause, then copy it to - // `name_` (a regular local Python won't delete) and rename all body references. let (Identifier identName) = identifier - let safeIdentName = identName + "_" - let safeParam = { param with Name = safeIdentName } - - let renamedBody = - FableTransforms.replaceValues (Map [ param.Name, Fable.IdentExpr safeParam ]) handlerBody - - let transformedBody = transformBlock com ctx returnStrategy renamedBody - // Annotated assignment: ex_: ExceptionType = ex - let saveStmt = - Statement.assign (identAsExpr com ctx safeParam, exnType, value = Expression.name identName) - ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = saveStmt :: transformedBody) + // Python's `except E as name:` deletes `name` at the end of the except block. + // If the exception variable is used (e.g., captured in a deferred closure), copy + // it to a safe local with a unique name that Python won't delete, and rename all + // body references to use the safe copy. + if isIdentUsed param.Name handlerBody then + let safeIdentName = getUniqueNameInDeclarationScope ctx (identName + "_") + let safeParam = { param with Name = safeIdentName } + + let renamedBody = + FableTransforms.replaceValues (Map [ param.Name, Fable.IdentExpr safeParam ]) handlerBody + + let transformedBody = transformBlock com ctx returnStrategy renamedBody + // Annotated assignment: ex_: ExceptionType = ex + let saveStmt = + Statement.assign (identAsExpr com ctx safeParam, exnType, value = Expression.name identName) + + ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = saveStmt :: transformedBody) + else + let transformedBody = transformBlock com ctx returnStrategy handlerBody + ExceptHandler.exceptHandler (``type`` = Some exnType, name = identifier, body = transformedBody) let handlers, handlerStmts = match catch with From c1c2d11d09dc19f858bc102c268ec70cc3c12c0a Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Wed, 4 Mar 2026 00:39:49 +0100 Subject: [PATCH 4/4] Revert quicktest-py/quicktest.fsx to pre-PR state Co-Authored-By: Claude Sonnet 4.6 --- src/quicktest-py/quicktest.fsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/quicktest-py/quicktest.fsx b/src/quicktest-py/quicktest.fsx index 7b2ad3f0d9..d0fb0f6474 100644 --- a/src/quicktest-py/quicktest.fsx +++ b/src/quicktest-py/quicktest.fsx @@ -36,24 +36,14 @@ let throwsAnyError (f: unit -> 'a) : unit = if success then printfn "[ERROR EXPECTED]" -// Reproduction: exception variable captured in a deferred closure (not immediately called) -let testExCapturedInDeferredClosure () = - let getMsg = - try - failwith "boom" - fun () -> "no error" - with ex -> - fun () -> ex.Message // closure captures ex, not immediately called - - getMsg () // called AFTER the try/with block - [] let main argv = let name = Array.tryHead argv |> Option.defaultValue "Guest" printfn $"Hello {name}!" - let msg = testExCapturedInDeferredClosure () - printfn $"Caught: {msg}" + // Open file with builtin `open` + // use file = builtins.``open``(StringPath "data.txt") + // file.read() |> printfn "File contents: %s" printfn "All tests passed!"