@@ -142,6 +142,91 @@ def runReleaseStress : IO Unit := do
142142 if Buffer.size b != 0 then
143143 throw <| IO.userError s! "release size reset: expected 0, got { Buffer.size b} "
144144
145+ /--
146+ Build `k` distinct length-`n` buffers and force their allocation immediately.
147+
148+ The fill value varies per `(salt, index)` for two reasons: within a call it stops the compiler from
149+ hoisting one shared `full` out of the inner loop, and across calls a distinct `salt` keeps the whole
150+ expression from being treated as loop-invariant (and hoisted out of the *caller's* loop) or CSE'd with
151+ another call site — either of which would allocate the buffers once, outside the arena under test. The
152+ returned element-count total is a forcing witness the caller checks.
153+ -/
154+ def buildArenaScratch (n : UInt32) (k : Nat) (salt : Nat) : Array Buffer × Nat :=
155+ Id.run do
156+ let mut held : Array Buffer := Array.mkEmpty k
157+ for i in [0 :k] do
158+ held := held.push (Buffer.full n (1 .0 + Float.ofNat (salt * k + i)))
159+ let mut touched : Nat := 0
160+ for b in held do
161+ touched := touched + (Buffer.size b).toNat
162+ return (held, touched)
163+
164+ def runArenaStress : IO Unit := do
165+ IO.println "== cuda arena scope stress =="
166+
167+ let n : UInt32 := 4096
168+ let k : Nat := 64
169+ let blocks : Nat := 4
170+ let expectTouched := k * n.toNat
171+ let base ← Buffer.allocatorStats
172+ IO.println s! " baseline: { base.format} "
173+
174+ -- Reclaim path: `k` buffers are built and held live (never released) inside an arena, kept alive
175+ -- across the scope exit, and reclaimed anyway. This is the case explicit `release` cannot reach: the
176+ -- buffers stay GC-reachable for the whole scope, so only the arena can free them.
177+ for blockIdx in [0 :blocks] do
178+ let before ← Buffer.allocatorStatsWithToken (UInt32.ofNat blockIdx)
179+ Buffer.arenaEnter
180+ let (held, touched) := buildArenaScratch n k blockIdx
181+ if touched != expectTouched then
182+ throw <| IO.userError s! "arena: scratch build under-allocated ({ touched} vs { expectTouched} )"
183+ let inside ← Buffer.allocatorStatsWithToken (UInt32.ofNat (blockIdx + 100 ))
184+ if inside.allocCount != before.allocCount + UInt64.ofNat k then
185+ throw <| IO.userError
186+ s! "arena: expected { k} in-scope allocations ({ before.allocCount} → { inside.allocCount} )"
187+ if inside.freeCount != before.freeCount then
188+ throw <| IO.userError
189+ s! "arena: buffers freed before scope exit ({ before.freeCount} → { inside.freeCount} )"
190+ -- Keep nothing: every in-scope buffer is reclaimed even though `held` still references them all.
191+ Buffer.arenaExit #[]
192+ -- Touch `held` *after* the exit so it stays live across it: this proves the ARENA did the freeing,
193+ -- not reference-counted finalization (which cannot run while `held` is still referenced).
194+ let heldGuard := held.size
195+ let after ← Buffer.allocatorStatsWithToken (UInt32.ofNat (blockIdx + 200 ))
196+ if heldGuard != k then
197+ throw <| IO.userError s! "arena: held guard mismatch ({ heldGuard} vs { k} )"
198+ if after.freeCount != before.freeCount + UInt64.ofNat k then
199+ throw <| IO.userError
200+ s! "arena: scope exit freed { after.freeCount - before.freeCount} , expected { k} live buffers"
201+ if after.liveBytes != before.liveBytes then
202+ throw <| IO.userError
203+ s! "arena: live bytes not restored at scope exit ({ before.liveBytes} → { after.liveBytes} )"
204+ IO.println s! " reclaimed { k} live in-scope buffers across { blocks} arenas"
205+
206+ -- Promotion path: a kept buffer survives the scope and stays usable; the rest are reclaimed.
207+ let before ← Buffer.allocatorStats
208+ Buffer.arenaEnter
209+ let keeper := Buffer.full n 2 .0
210+ -- Force `keeper`'s allocation inside the scope (so it is registered and then promoted on exit).
211+ if Buffer.size keeper != n then
212+ throw <| IO.userError "arena: keep-path keeper not allocated"
213+ let (scratch, scratchTouched) := buildArenaScratch n k blocks
214+ if scratchTouched != expectTouched then
215+ throw <| IO.userError "arena: keep-path scratch build under-allocated"
216+ Buffer.arenaExit #[keeper]
217+ let scratchGuard := scratch.size
218+ let after ← Buffer.allocatorStats
219+ if scratchGuard != k then
220+ throw <| IO.userError "arena: keep-path scratch guard mismatch"
221+ if after.freeCount != before.freeCount + UInt64.ofNat k then
222+ throw <| IO.userError
223+ s! "arena keep: expected { k} frees, got { after.freeCount - before.freeCount} "
224+ -- `keeper` was promoted out of the scope, so its data is intact: reducing it still sees `2.0`.
225+ let keptSum := (Buffer.toFloatArray (Buffer.reduceSum keeper)).get! 0
226+ let expectedSum := 2 .0 * Float.ofNat n.toNat
227+ Utils.assertApprox "arena kept-buffer survives scope" keptSum expectedSum (tol := 1e-1 )
228+ IO.println " promoted buffer survived its arena"
229+
145230@[noinline] def runWrapperLifetimeIteration (i : Nat) : IO Unit := do
146231 let host := FloatArray.mk #[i.toFloat, 2 .0 , -3 .0 , 4 .0 ]
147232 let a ← Buffer.ofFloatArrayIO host
@@ -407,10 +492,102 @@ def runMatmulStress : IO Unit := do
407492 Utils.assertTensorApprox (s := sY2) "matmul stress case2 fp32" yFp322 yRef2 (tol := 7e-3 )
408493 Utils.assertTensorApprox (s := sY2) "matmul stress case2 fp64" yFp642 yRef2 (tol := 1e-9 )
409494
495+ /--
496+ Planted use-after-free, the subject of `runArenaDetectorDeathTest`. Allocates two buffers inside an
497+ arena, reclaims **both** at `arenaExit`, then uses them in an op — the same hazard as touching a
498+ `release`d buffer. Because reclaimed buffers have `size == 0`, the bare size check (`0 == 0`) lets this
499+ slip through to a launch on freed memory; only the detector (`TORCHLEAN_ARENA_DEBUG=1`) catches it,
500+ naming the epoch. Run only in a forked child (selected by `TORCHLEAN_ARENA_UAF_PROBE=uaf`): under the
501+ detector it `panic`s (which is why it must be forked, not asserted in-process); with the detector off it
502+ returns a silently-wrong size-0 result — exactly the silent corruption the detector closes. -/
503+ def runArenaUseAfterFreeProbe : IO Unit := do
504+ IO.println "== cuda arena use-after-free probe =="
505+ let n : UInt32 := 16
506+ Buffer.arenaEnter
507+ let a := Buffer.full n 3 .0
508+ let b := Buffer.full n 5 .0
509+ let forced := Buffer.size a + Buffer.size b -- force both allocations inside the epoch
510+ if forced != 2 * n then
511+ throw <| IO.userError "uaf probe: operands not allocated"
512+ Buffer.arenaExit #[] -- reclaim BOTH (keep nothing)
513+ -- `a` and `b` are now reclaimed (size 0). The detector asserts liveness before the size check and
514+ -- panics here; with the detector off, `add` slips past `0 == 0` and yields a silently-empty buffer.
515+ let bad := Buffer.add a b
516+ IO.println s! " detector OFF: use-after-free slipped through, result size = { Buffer.size bad} (expected 16)"
517+
518+ /--
519+ Valid arena promotion, the negative-case subject of `runArenaDetectorDeathTest`. Promotes one buffer
520+ past the scope and reclaims another, then uses the *promoted* buffer in a **binary** op — which flows
521+ through the same `require_same_size2` choke point the detector guards. A promoted buffer is live
522+ (`arena_freed_depth == 0`), so the detector must not fire and the result must be correct. Selected in a
523+ forked child by `TORCHLEAN_ARENA_UAF_PROBE=valid`. -/
524+ def runArenaValidPromotionProbe : IO Unit := do
525+ IO.println "== cuda arena valid-promotion probe =="
526+ let n : UInt32 := 16
527+ Buffer.arenaEnter
528+ let keep := Buffer.full n 2 .0
529+ let scratch := Buffer.full n 7 .0
530+ let forced := Buffer.size keep + Buffer.size scratch -- force both allocations inside the epoch
531+ if forced != 2 * n then
532+ throw <| IO.userError "valid-promotion probe: operands not allocated"
533+ Buffer.arenaExit #[keep] -- promote `keep`; reclaim `scratch`
534+ -- `keep` is promoted (live). A binary op on it exercises the detector's choke point and must pass.
535+ let ok := Buffer.add keep keep
536+ let s := (Buffer.toFloatArray (Buffer.reduceSum ok)).get! 0
537+ if s != 4 .0 * Float.ofNat n.toNat then
538+ throw <| IO.userError s! "valid-promotion probe: wrong result { s} (expected { 4 .0 * Float.ofNat n.toNat} )"
539+ IO.println s! " promoted buffer reused in a binary op, result sum = { s} "
540+
541+ /--
542+ Positive + negative regression test for the arena use-after-free detector. A detected UAF must `panic`
543+ (it cannot be caught in-process), so the suite binary is forked in each configuration via
544+ `/proc/self/exe` and its outcome inspected:
545+
546+ * **positive** — `TORCHLEAN_ARENA_DEBUG=1` + the planted UAF ⇒ the child aborts with a
547+ `use-after-arena-free` panic;
548+ * **negative (no false positive)** — detector on + a *valid* arena promotion
549+ (`runArenaValidPromotionProbe`, which reuses a promoted buffer in a binary op through the detector's
550+ own choke point) ⇒ the child exits cleanly, so the detector never fires on a kept buffer;
551+ * **control** — detector off + the planted UAF ⇒ the child exits cleanly (the UAF slips through, the
552+ silent corruption the detector closes).
553+
554+ The forked children re-enter the suite with `TORCHLEAN_ARENA_UAF_PROBE` set (see `NN.Tests.run`) and so
555+ run only the relevant fragment. Linux-only (uses `/proc/self/exe`); skipped with a note elsewhere. -/
556+ def runArenaDetectorDeathTest : IO Unit := do
557+ IO.println "== cuda arena use-after-free detector (fork death test) =="
558+ let self : System.FilePath := "/proc/self/exe"
559+ if !(← self.pathExists) then
560+ IO.println " skipped: no /proc/self/exe (fork death test is Linux-only)"
561+ return
562+ let contains (hay needle : String) : Bool := (hay.splitOn needle).length ≥ 2
563+ let fork (debug : Bool) (mode : String) : IO IO.Process.Output := do
564+ let env := #[("TORCHLEAN_ARENA_UAF_PROBE" , some mode)]
565+ let env := if debug then env.push ("TORCHLEAN_ARENA_DEBUG" , some "1" ) else env
566+ IO.Process.output { cmd := self.toString, args := #[], env := env }
567+ -- positive: the detector aborts the planted use-after-free, naming the hazard.
568+ let pos ← fork true "uaf"
569+ if pos.exitCode == 0 then
570+ throw <| IO.userError "arena UAF detector: detector ON did NOT abort the planted use-after-free"
571+ if !(contains (pos.stderr ++ pos.stdout) "use-after-arena-free" ) then
572+ throw <| IO.userError s! "arena UAF detector: detector ON aborted without the expected message; stderr:\n { pos.stderr} "
573+ IO.println " positive: detector ON aborts the planted use-after-free ✓"
574+ -- negative: a valid promotion under the detector is left untouched (no false positive).
575+ let neg ← fork true "valid"
576+ if neg.exitCode != 0 then
577+ throw <| IO.userError s! "arena UAF detector: false positive on a valid promotion (exit { neg.exitCode} ); stderr:\n { neg.stderr} "
578+ IO.println " negative: detector ON leaves a valid arena promotion untouched ✓"
579+ -- control: with the detector off the same use-after-free slips through and the child exits cleanly.
580+ let off ← fork false "uaf"
581+ if off.exitCode != 0 then
582+ throw <| IO.userError s! "arena UAF detector: with the detector off the probe should exit cleanly (exit { off.exitCode} )"
583+ IO.println " control: detector OFF leaves the use-after-free undetected, as designed ✓"
584+
410585def run : IO Unit := do
411586 IO.println "=== CUDA runtime stress suite ==="
412587 runRngStress
413588 runReleaseStress
589+ runArenaStress
590+ runArenaDetectorDeathTest
414591 runWrapperLifetimeStress
415592 runGradientAliasingStress
416593 runMalformedBufferValidationStress
0 commit comments