This PR makes an empty `by` block run `try?` in the background and surface its suggestions, while still producing the usual unsolved-goals diagnostic. The implicit `try?` is informational only — it does not change elaboration behavior beyond emitting messages. Behaviour is controlled by a new option `tactic.tryOnEmptyBy`, disabled by default for now; set it to `true` to opt in. The default may flip in a future release. Behaviour summary, when the option is enabled: * The empty `by` reports unsolved goals immediately, before the (possibly slow) `try?` has finished. * The `try?` work is spawned as an asynchronous snapshot task (`Term.wrapAsyncAsSnapshot` + `Core.logSnapshotTask`), so subsequent elaboration is not blocked and the suggestions arrive when ready. * `try?` is gated on its parser infrastructure being available, so working on the prelude (before `Init.Try` is imported) keeps the regular empty-`by` behaviour. * No effect when the empty `by` appears inside a backtracking combinator (e.g. `first | exact (by) | …`) or when `try?` finds no applicable suggestion. Implementation notes: * `elabEmptyByAsTry` (in `Lean.Elab.Tactic.Try`) is registered as a second `@[builtin_term_elab byTactic]`, alongside the existing `elabByTactic` in `Lean.Elab.BuiltinTerm`. The gate `shouldElabEmptyByAsTry` is checked in both elaborators so the empty-`by` path takes the `try?` route while non-empty `by` follows the regular path. The body shared between them is factored as `elabByTacticCore`. The two-elaborator setup avoids a circular module dependency between `BuiltinTerm.lean` and `Tactic/Try.lean`; an inline comment in `Try.lean` explains this. * A latent bug from #13229 is fixed along the way: `evalSepTactics` returned at the very top for an empty tactic sequence without resolving the `tacSnap` promise that `MutualDef.mkTacTask` sets up for `:= by …` bodies. The dangling promise was harmless in typical use because the cmd's cancellation token would fire shortly after elaboration and drop it, but with a slow async snapshot task in the same command (as the implicit `try?` here) the language-server info-tree walk would block on it and the editor's Messages view would only update once the task finished. Resolved at the early-return in `evalSepTactics`. * The test infrastructure in `Lean.Server.Test.Cancel` gains a label-keyed `testTasksRef` registry plus `mkTestTask` / `wait_for_test_task`. The pre-existing `block_until_cancelled` is reimplemented on top of `mkTestTask` and the redundant `blockUntilCancelledOnce` ref is removed. Tests: * `tests/elab/tryOnEmptyBy.lean`, `tests/elab/try_prelude.lean` — feature behaviour and prelude gating. * `tests/server_interactive/cancellation_empty_by.lean` — verifies that on document re-elaboration `cancelRec` reaches the empty-`by` snapshot's cancel token registered with `Core.logSnapshotTask`. A `[try_suggestion]` generator wires the outer cancel token's `onSet` to resolve a `mkTestTask "T_outer"` promise, and the candidate `wait_for_test_task "T_outer"` waits on it. If `cancelTk? := none` is passed to `Core.logSnapshotTask`, `cancelRec` cannot reach the token, the wait blocks, and the runner times out. If `cancelTk? := none` is also passed to `wrapAsyncAsSnapshot`, no `onSet` resolver is registered, the promise drops without resolution, and `wait_for_test_task` surfaces a `"task dropped"` diagnostic on stderr. * `tests/server_interactive/cancellation_try_plain.lean` — verifies cancellation of plain `try?` (no `=>`) when its `[try_suggestion]` candidate runs synchronously inside `expandUserTactic`, by chaining through `wait_for_cancel_once_async`'s shared promise. Breaking `SnapshotTask.cancelRec` to skip walking children causes a runner timeout. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
279 lines
10 KiB
Text
279 lines
10 KiB
Text
/-
|
|
Copyright (c) 2025 Lean FRO. All rights reserved.
|
|
Released under Apache 2.0 license as described in the file LICENSE.
|
|
|
|
Authors: Sebastian Ullrich
|
|
-/
|
|
module
|
|
|
|
prelude
|
|
public import Lean.Elab.Command
|
|
public import Lean.Elab.Tactic.Basic
|
|
public meta import Lean.Elab.Command
|
|
public meta import Lean.Elab.Tactic.Basic
|
|
|
|
public section
|
|
|
|
/-!
|
|
Helpers for testing cancellation in interactive tests. Put here because of `initialize` restrictions
|
|
and to avoid repeated elaboration overhead per test.
|
|
-/
|
|
|
|
namespace Lean.Server.Test.Cancel
|
|
|
|
meta initialize onceRef : IO.Ref (Option (Task Unit)) ← IO.mkRef none
|
|
|
|
/--
|
|
On first invocation, sends a diagnostics "blocked", blocks until cancelled, and then eprints
|
|
"cancelled!"; further invocations complete when this wait is done but do not wait for their own
|
|
cancellation. Thus all document versions should complete strictly after the printing has completed
|
|
and we avoid terminating the server too early to see the message.
|
|
-/
|
|
scoped syntax "wait_for_cancel_once" : tactic
|
|
@[incremental]
|
|
elab_rules : tactic
|
|
| `(tactic| wait_for_cancel_once) => do
|
|
let prom ← IO.Promise.new
|
|
if let some t := (← onceRef.modifyGet (fun old => (old, old.getD prom.result!))) then
|
|
IO.wait t
|
|
return
|
|
|
|
dbg_trace "blocked!"
|
|
log "blocked"
|
|
let ctx ← readThe Elab.Term.Context
|
|
let some tacSnap := ctx.tacSnap? | unreachable!
|
|
tacSnap.new.resolve {
|
|
diagnostics := (← Language.Snapshot.Diagnostics.ofMessageLog (← Core.getMessageLog))
|
|
stx := default
|
|
finished := default
|
|
}
|
|
|
|
let ctx ← readThe Core.Context
|
|
let some cancelTk := ctx.cancelTk? | unreachable!
|
|
-- TODO: `CancelToken` should probably use `Promise`
|
|
while true do
|
|
if (← cancelTk.isSet) then
|
|
break
|
|
IO.sleep 30
|
|
IO.eprintln "cancelled!"
|
|
log "cancelled (should never be visible)"
|
|
prom.resolve ()
|
|
Core.checkInterrupted
|
|
|
|
-- CancelToken is Promise-based, so we can't create one during `initialize`
|
|
-- (task manager not yet ready). Create lazily on first use, atomically via `modifyGet`
|
|
-- to avoid two callers each constructing a token and only one being stored.
|
|
meta initialize unblockedCancelTkRef : IO.Ref (Option IO.CancelToken) ← IO.mkRef none
|
|
|
|
private meta def getUnblockedCancelTk : BaseIO IO.CancelToken := do
|
|
let fresh ← IO.CancelToken.new
|
|
unblockedCancelTkRef.modifyGet fun
|
|
| some tk => (tk, some tk)
|
|
| none => (fresh, some fresh)
|
|
|
|
/--
|
|
Waits for `unblock` to be called, which is expected to happen in a subsequent document version that
|
|
does not invalidate this tactic. Complains if cancellation token was set before unblocking, i.e. if
|
|
the tactic was invalidated after all.
|
|
-/
|
|
scoped syntax "wait_for_unblock" : tactic
|
|
@[incremental]
|
|
elab_rules : tactic
|
|
| `(tactic| wait_for_unblock) => do
|
|
let ctx ← readThe Core.Context
|
|
let some cancelTk := ctx.cancelTk? | unreachable!
|
|
|
|
dbg_trace "blocked!"
|
|
log "blocked"
|
|
let ctx ← readThe Elab.Term.Context
|
|
let some tacSnap := ctx.tacSnap? | unreachable!
|
|
tacSnap.new.resolve {
|
|
diagnostics := (← Language.Snapshot.Diagnostics.ofMessageLog (← Core.getMessageLog))
|
|
stx := default
|
|
finished := default
|
|
}
|
|
|
|
while true do
|
|
if (← (← getUnblockedCancelTk).isSet) then
|
|
break
|
|
IO.sleep 30
|
|
if (← cancelTk.isSet) then
|
|
IO.eprintln "cancelled!"
|
|
log "cancelled (should never be visible)"
|
|
|
|
/--
|
|
Spawns a `logSnapshotTask` that waits for `unblock` to be called, which is expected to happen in a
|
|
subsequent document version that does not invalidate this tactic. Complains if cancellation token
|
|
was set before unblocking, i.e. if the tactic was invalidated after all.
|
|
-/
|
|
elab "wait_for_unblock_async" : tactic => do
|
|
let cancelTk ← IO.CancelToken.new
|
|
let act ← Elab.Term.wrapAsyncAsSnapshot (cancelTk? := cancelTk) fun _ => do
|
|
let ctx ← readThe Core.Context
|
|
let some cancelTk := ctx.cancelTk? | unreachable!
|
|
while true do
|
|
if (← (← getUnblockedCancelTk).isSet) then
|
|
break
|
|
IO.sleep 30
|
|
if (← cancelTk.isSet) then
|
|
IO.eprintln "cancelled!"
|
|
log "cancelled (should never be visible)"
|
|
let t ← BaseIO.asTask (act ())
|
|
Core.logSnapshotTask { stx? := none, task := t, cancelTk? := cancelTk }
|
|
|
|
log "blocked"
|
|
|
|
/-- Unblocks a `wait_for_unblock*` task. -/
|
|
scoped elab "unblock" : tactic => do
|
|
dbg_trace "unblocking!"
|
|
(← getUnblockedCancelTk).set
|
|
|
|
/--
|
|
Like `wait_for_cancel_once` but does the waiting in a separate task and waits for its
|
|
cancellation.
|
|
-/
|
|
scoped syntax "wait_for_cancel_once_async" : tactic
|
|
@[incremental]
|
|
elab_rules : tactic
|
|
| `(tactic| wait_for_cancel_once_async) => do
|
|
let prom ← IO.Promise.new
|
|
if let some t := (← onceRef.modifyGet (fun old => (old, old.getD prom.result!))) then
|
|
IO.wait t
|
|
return
|
|
|
|
let cancelTk ← IO.CancelToken.new
|
|
let act ← Elab.Term.wrapAsyncAsSnapshot (cancelTk? := cancelTk) fun _ => do
|
|
let ctx ← readThe Core.Context
|
|
let some cancelTk := ctx.cancelTk? | unreachable!
|
|
-- TODO: `CancelToken` should probably use `Promise`
|
|
while true do
|
|
if (← cancelTk.isSet) then
|
|
break
|
|
IO.sleep 30
|
|
IO.eprintln "cancelled!"
|
|
log "cancelled (should never be visible)"
|
|
prom.resolve ()
|
|
Core.checkInterrupted
|
|
let t ← BaseIO.asTask (act ())
|
|
Core.logSnapshotTask { stx? := none, task := t, cancelTk? := cancelTk }
|
|
|
|
dbg_trace "blocked!"
|
|
log "blocked"
|
|
|
|
/--
|
|
Like `wait_for_cancel_once_async` but waits for the main thread's cancellation token. This is useful
|
|
to test main thread cancellation in non-incremental contexts because we otherwise wouldn't be able
|
|
to send out the "blocked" message from there.
|
|
-/
|
|
scoped syntax "wait_for_main_cancel_once_async" : tactic
|
|
@[incremental]
|
|
elab_rules : tactic
|
|
| `(tactic| wait_for_main_cancel_once_async) => do
|
|
let prom ← IO.Promise.new
|
|
if let some t := (← onceRef.modifyGet (fun old => (old, old.getD prom.result!))) then
|
|
IO.wait t
|
|
return
|
|
|
|
let some cancelTk := (← readThe Core.Context).cancelTk? | unreachable!
|
|
let act ← Elab.Term.wrapAsyncAsSnapshot (cancelTk? := none) fun _ => do
|
|
let ctx ← readThe Core.Context
|
|
-- TODO: `CancelToken` should probably use `Promise`
|
|
while true do
|
|
if (← cancelTk.isSet) then
|
|
break
|
|
IO.sleep 30
|
|
IO.eprintln "cancelled!"
|
|
log "cancelled (should never be visible)"
|
|
prom.resolve ()
|
|
Core.checkInterrupted
|
|
let t ← BaseIO.asTask (act ())
|
|
Core.logSnapshotTask { stx? := none, task := t, cancelTk? := cancelTk }
|
|
|
|
dbg_trace "blocked!"
|
|
log "blocked"
|
|
|
|
meta initialize cmdOnceRef : IO.Ref (Option (Task Unit)) ← IO.mkRef none
|
|
|
|
/--
|
|
Like `wait_for_main_cancel_once_async` but for commands. Takes a `num` parameter so that its syntax
|
|
can be changed (via `change:`) to trigger re-elaboration. Sends "blocked" as a diagnostic and spawns
|
|
an async task that waits for the command's cancellation token to be set.
|
|
-/
|
|
scoped syntax "wait_for_cancel_once_command" num : command
|
|
elab_rules : command
|
|
| `(command| wait_for_cancel_once_command $_n) => Elab.Command.liftCoreM do
|
|
let prom ← IO.Promise.new
|
|
if let some t := (← cmdOnceRef.modifyGet (fun old => (old, old.getD prom.result!))) then
|
|
IO.wait t
|
|
return
|
|
let some cancelTk := (← read).cancelTk? | unreachable!
|
|
let act ← Core.wrapAsyncAsSnapshot (cancelTk? := none) fun _ => do
|
|
while true do
|
|
if (← cancelTk.isSet) then
|
|
break
|
|
IO.sleep 30
|
|
IO.eprintln "cancelled!"
|
|
logInfo "cancelled (should never be visible)"
|
|
prom.resolve ()
|
|
Core.checkInterrupted
|
|
let t ← BaseIO.asTask (act ())
|
|
(Core.logSnapshotTask { stx? := none, task := t, cancelTk? := cancelTk })
|
|
logInfo "blocked"
|
|
|
|
/-- Registry of label-keyed `Task (Option Unit)` values for use by `mkTestTask` and
|
|
`wait_for_test_task`. The stored task is `prom.result?` of the promise returned by
|
|
`mkTestTask`; the registry itself does not keep that promise alive, so if no other
|
|
reference exists, the promise drops and the task fires `none`. -/
|
|
meta initialize testTasksRef : IO.Ref (Std.HashMap String (Task (Option Unit))) ← IO.mkRef {}
|
|
|
|
/-- Register a fresh test task under `label`, returning the underlying `IO.Promise`.
|
|
Returns `none` if a task is already registered under `label`. The caller is responsible
|
|
for keeping the returned promise alive and arranging its resolution -- typically by
|
|
capturing it in a `cancelTk.onSet` closure that calls `prom.resolve`. -/
|
|
meta def mkTestTask (label : String) : BaseIO (Option (IO.Promise Unit)) := do
|
|
let prom ← IO.Promise.new
|
|
testTasksRef.modifyGet fun m =>
|
|
if m.contains label then (none, m) else (some prom, m.insert label prom.result?)
|
|
|
|
/-- Block until the test task named `label` fires. Prints a diagnostic to stderr if
|
|
the underlying promise was dropped without resolution, or if no task is registered for
|
|
`label`. The diagnostic uses stderr rather than `throwError` so that the failure is
|
|
visible even when this tactic is evaluated inside `try?` (or any other combinator that
|
|
swallows tactic errors). -/
|
|
scoped syntax "wait_for_test_task " str : tactic
|
|
elab_rules : tactic
|
|
| `(tactic| wait_for_test_task $label) => do
|
|
let label := label.getString
|
|
match (← testTasksRef.get).get? label with
|
|
| none =>
|
|
IO.eprintln s!"wait_for_test_task: no task registered for {label}"
|
|
| some t =>
|
|
match (← IO.wait t) with
|
|
| some _ => return
|
|
| none => IO.eprintln s!"wait_for_test_task: task {label} dropped without resolution"
|
|
|
|
/--
|
|
Tactic for testing cancellation propagation. On the first invocation for a given `<label>`,
|
|
prints `<label>: blocked` to stderr and loops on `Core.checkInterrupted` until the tactic's
|
|
cancel token fires (at which point the loop throws and `finally` resolves the shared task).
|
|
Subsequent invocations (e.g. on re-elaboration) wait on that task: they return as soon as
|
|
the first invocation has actually exited the loop, and hang otherwise. So if cancellation
|
|
propagates correctly, the test completes; if propagation is broken, the second invocation's
|
|
wait blocks forever and the test hangs (timeout = failure).
|
|
-/
|
|
scoped syntax "block_until_cancelled" str : tactic
|
|
elab_rules : tactic
|
|
| `(tactic| block_until_cancelled $label) => do
|
|
let lbl := label.getString
|
|
match (← mkTestTask lbl) with
|
|
| none =>
|
|
let some t := (← testTasksRef.get).get? lbl | unreachable!
|
|
discard <| IO.wait t
|
|
| some prom =>
|
|
IO.eprintln s!"{lbl}: blocked"
|
|
try
|
|
while true do
|
|
Core.checkInterrupted
|
|
IO.sleep 10
|
|
finally
|
|
prom.resolve ()
|