lean4-htt/src/Lean/Server/Test/Cancel.lean
Joachim Breitner 2229b077d6
feat: empty by runs try? to suggest a proof (#13430)
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>
2026-05-11 06:31:42 +00:00

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 ()