fix: expand reset reuse in the presence of double oproj (#13421)

This PR fixes an issue in the expand reset reuse pass that causes
segfaults in very rare situations.

This bug occurs in situations where two projections from the same field
happen right before a reset,
for example:
```
let x.2 := oproj[0] _x.1;
inc x.2;
let x.3 := oproj[0] _x.1;
inc x.3;
let _x.4 := reset[1] _x.1;
```
when expand reset reuse we optimize situations like this to only `inc`
on the cold path as on the
hot path we are going to keep the projectees alive until at least
`reuse` by just not `dec`-ing the
resetee. However, the algorithm for this assumed that we do not project
more than once from each
field and thus removed both `inc x.2` and `inc x.3` which is too much.

The bug was masked compared to the original #13407 that was reproducible
in 4.29, because the
presented code relied on semantics of global constants which were
changed in 4.30. The PR contains a
modified (and more consistent) reproducer.

Closes: #13407
Co investigated with @Rob23oba
This commit is contained in:
Henrik Böving 2026-04-15 21:16:22 +02:00 committed by GitHub
parent fe77e4d2d1
commit 5949ae8664
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 80 additions and 0 deletions

View file

@ -90,6 +90,22 @@ partial def eraseProjIncFor (nFields : Nat) (targetId : FVarId) (ds : Array (Cod
| break
if !(w == z && targetId == x) then
break
if mask[i]!.isSome then
/-
Suppose we encounter a situation like
```
let x.1 := proj[0] y
inc x.1
let x.2 := proj[0] y
inc x.2
```
The `inc x.2` will already have been removed. If we don't perform this check we will also
remove `inc x.1` and have effectively removed two refcounts while only one was legal.
-/
keep := keep.push d
keep := keep.push d'
ds := ds.pop.pop
continue
/-
Found
```

64
tests/elab/13407.lean Normal file
View file

@ -0,0 +1,64 @@
module
/-! Regression test for issue 13407 -/
inductive Result (α : Type) where | ok (x : α) | err
instance : Monad Result where
pure x := .ok x
bind s f := match s with | .ok x => f x | .err => .err
instance : Coe α (Result α) where coe x := .ok x
structure Int' (bits : Nat) where val : Nat
def Int'.wrap (n : Int) (bits : Nat) : Int' bits := ⟨(n % (2^bits : Int)).toNat⟩
def Int'.toInt (x : Int' bits) : Int :=
if x.val < 2^(bits - 1) then ↑x.val else ↑x.val - ↑(2^bits)
instance (n : Nat) : OfNat (Int' bits) n where ofNat := ⟨n % (2^bits)⟩
instance : Neg (Int' bits) where neg x := Int'.wrap (-Int'.toInt x) bits
instance : Repr (Int' bits) := ⟨fun x _ => repr (Int'.toInt x)⟩
class BinOp1 (α β : Type) (γ : outParam Type) where binOp1 : α → β → γ
class BinOp2 (α β : Type) (γ : outParam Type) where binOp2 : α → β → γ
class UnaryOp (α : Type) (β : outParam Type) where unaryOp : α → β
class Cast (α β : Type) where cast : α → Result β
instance : BinOp1 (Int' b) (Int' b) (Result (Int' b)) where
binOp1 a c := .ok (Int'.wrap (a.toInt + c.toInt) b)
instance : BinOp1 (Int' l) (Int' r) (Result (Int' l)) where
binOp1 a c := .ok (Int'.wrap (a.toInt + c.toInt) l)
instance : BinOp2 (Int' b) (Int' b) (Result (Int' b)) where
binOp2 a c := .ok (Int'.wrap (a.toInt + c.toInt) b)
instance : UnaryOp (Int' b) (Result (Int' b)) where
unaryOp a := .ok (Int'.wrap (-(a.toInt + 1)) b)
instance : Cast (Int' n) (Int' m) where
cast x := .ok (Int'.wrap x.toInt m)
set_option linter.unusedVariables false
def helper (_ : Nat) : Result (Int' 64) := UnaryOp.unaryOp (1 : Int' 64)
def test (a : Nat) : Result (Int' 16) := do
let x ← UnaryOp.unaryOp (1 : Int' 16)
let y ← BinOp2.binOp2
(← (Cast.cast (← helper a) : Result (Int' 128)))
(← BinOp1.binOp1
(← (Cast.cast (← helper a) : Result (Int' 128)))
(← BinOp2.binOp2
(← BinOp1.binOp1 (10 : Int' 128) (1 : Int' 128))
(← BinOp2.binOp2 (7 : Int' 128) (3 : Int' 128))))
BinOp1.binOp1
(← (Cast.cast (← (Cast.cast y : Result (Int' 128))) : Result (Int' 16)))
(← BinOp2.binOp2 x (← BinOp2.binOp2 x x))
/-- info: 11 -/
#guard_msgs in
#eval do
match test 0 with
| .ok v => IO.println (repr v)
| .err => IO.println "ERR"