Platform
a2a3 (Ascend 910B/C hardware)
Runtime Variant
tensormap_and_ringbuffer
Description
PTO2TaskAllocator::try_bump_heap
(src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h) fails an
allocation of size S (with S <= heap_size_) when the ring is already
drained (heap_top_ == heap_tail_, used == 0) but parked at a non-zero
offset d — even though the whole ring is free. When heap_size_ = R lands in
[S, 2S): the tail arc has only R - d < S bytes, and wrapping to the head arc
is rejected by the strict > guard (d > S is false at d == S). The allocator
then latches orch_error_code=2 HEAP_RING_DEADLOCK with no live bytes in the
ring. This is a pure allocator-arithmetic defect — an empty ring failing to use
its own capacity — independent of any upper-layer workload.
Steps to Reproduce
Add a minimal ST (adds `tests/` only, no `src/` runtime change):
`tests/st/a2a3/tensormap_and_ringbuffer/heap_empty_ring_rebase/`. One 1 MiB
scratch (`[1024,256] float32`) is reused across two **consecutive scopes** (each
depth 1 → ring 1): iteration 1's `scope_end` reclaims T1, draining the ring to
`top == tail == S` (parked at offset S); iteration 2 allocates a same-size T2 →
hits the empty-ring wrap defect. Squeeze ring 1's heap to `1.5 x S = 1.5 MiB`
(inside `[S, 2S)`):
PTO2_RING_HEAP=268435456,1572864,268435456,268435456 \
python -m pytest \
tests/st/a2a3/tensormap_and_ringbuffer/heap_empty_ring_rebase/test_heap_empty_ring_rebase.py \
-s -v -p no:xdist --platform a2a3 --device <N>
(On a shared box wrap it in `task-submit --device <N> --run "..."` with a per-run
`ASCEND_PROCESS_LOG_PATH`.)
Expected Behavior
A fully drained ring must accommodate any allocation with S <= heap_size_.
Iteration 2 should succeed and the test should pass with Y1[0] == Y2[0] == 42.
Actual Behavior
Iteration 2's allocation fails on the empty ring; the host reports:
[ERROR] validate_runtime_impl: PTO2 runtime failed: orch_error_code=2 sched_error_code=0 runtime_status=-2
[ERROR] validate_runtime_impl: error detail: orch_error_code=2 HEAP_RING_DEADLOCK -
the ring task allocator ran out of both task slots and heap bytes, so no further task can be admitted
(Host-side 507018 ACL_ERROR_RT_AICPU_EXCEPTION; device-classified reason =
orch_error_code=2 HEAP_RING_DEADLOCK.)
Git Commit ID
6284040
CANN Version
9.0.0
Driver Version
26.0.rc1
Host Platform
Linux (aarch64)
Additional Context
Fix corroboration (same ST file, same PTO2_RING_HEAP, only the runtime
differs): the fixed branch passes with Y1[0] == Y2[0] == 42 (1 passed).
Since the fix fits both 1 MiB scratches into the 1.5 MiB ring, the ring is
demonstrably large enough — upstream's failure is not a capacity shortfall but
the empty-ring arithmetic refusing an allocation that fits.
Root cause: the failure path of try_bump_heap lacks an empty-ring rebase.
When top == tail (used == 0) is parked at a non-zero offset and neither arc
fits, the span should be rebased to heap_base_
(heap_top_ = alloc_size; heap_tail_ = 0;). The strict > wrap guard itself is
correct (needed for the full/empty ambiguity); the missing case is the empty
ring.
Suggested fix — add to the top >= tail failure path of try_bump_heap:
} else if (top == tail && alloc_size <= heap_size_) {
// The ring is empty (top == tail, used == 0) but parked at a non-zero
// offset; this allocation fits neither arc even though the whole ring is
// free. Rebase to 0 to make the span contiguous.
result = heap_base_;
heap_top_ = alloc_size;
heap_tail_ = 0;
}
lib-free regression test — test_task_allocator.cpp::HeapEmptyRingRebasesForContiguousAlloc
TEST_F(TaskAllocatorTest, HeapEmptyRingRebasesForContiguousAlloc) {
constexpr uint64_t S = 3000; // > HEAP_SIZE/2: the ring holds one at a time
auto r1 = allocator.alloc(S);
ASSERT_FALSE(r1.failed());
const uint64_t top_after = allocator.heap_top();
// Reclaim task 0 fully: tail advances to its extent end == top, draining the
// ring to empty (top == tail) while parked at a non-zero offset.
consume_up_to(descriptors, last_alive, heap_buf, WINDOW_SIZE, 1, top_after);
// The whole ring is free, so the next same-size allocation must succeed by
// rebasing to the heap base.
auto r2 = allocator.alloc(S);
ASSERT_FALSE(r2.failed()) << "empty ring must place an allocation that fits its whole span";
EXPECT_EQ(r2.packed_base, static_cast<void *>(heap_buf));
EXPECT_EQ(allocator.heap_top(), top_after);
}
Reproduced-at-HEAD: dynamically reproduced on main HEAD 62840403 (real
a2a3, orch_error_code=2 HEAP_RING_DEADLOCK, 1 failed) as well as on fork point
8cdb306c. Consistently, pto_ring_buffer.h (containing try_bump_heap) is
byte-identical across 8cdb306c..62840403 — the 36 intervening commits
touch only pto_orchestrator.cpp (dense-dep diagnostics #1448, marker
consolidation #1410, dispatch marker #1411, early-dispatch #1405), none of the
allocator logic.
Reproduction range & fix: reproduced across 8cdb306c..HEAD 62840403, over
which pto_ring_buffer.h is byte-identical — so this is not a regression from
those commits. The introducing commit was not bisected; the missing empty-ring
branch in try_bump_heap is a long-standing gap in the allocator, not a recent
change. Fixed on our fork by fd82ae69 ("Fix: rebase a drained ring heap to its
base for contiguous reuse", applied to all three runtimes).
Reproduction — full ST sources
Drop these into tests/st/a2a3/tensormap_and_ringbuffer/heap_empty_ring_rebase/, then
run the command in Steps to Reproduce. No src/ change is required.
test_heap_empty_ring_rebase.py
#!/usr/bin/env python3
# Copyright (c) PyPTO Contributors.
# This program is free software, you can redistribute it and/or modify it under the terms and conditions of
# CANN Open Software License Agreement Version 2.0 (the "License").
# Please refer to the License for details. You may not use this file except in compliance with the License.
# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
# See LICENSE in the root of the software repository for the full text of the License.
# -----------------------------------------------------------------------------------------------------------
"""heap_empty_ring_rebase: reuse one S-byte scratch across two scopes on a ring
sized [S, 2S).
Each iteration is its own scope on the same ring (scope depth 1 -> ring 1), so
iteration 1's scope_end reclaims T1 and drains the ring to top == tail == S
(empty, but parked at the non-zero offset S). Iteration 2 then allocates T2 of
the same S bytes: the request fits neither the tail arc (heap_size - S < S) nor
the wrapped head arc (strict wrap guard tail > S is false at tail == S), so a
drained ring refuses an allocation that fits its whole span.
Run ring 1's heap down to 1.5x the scratch via env so it lands in [S, 2S):
PTO2_RING_HEAP=268435456,1572864,268435456,268435456 \
python -m pytest .../test_heap_empty_ring_rebase.py \
-s -v -p no:xdist --platform a2a3 --device <N>
Expected on a2a3 (verified): fixed simpler passes and Y1[0]==Y2[0]==42; upstream
8cdb306c fails with host "orch_error_code=2 HEAP_RING_DEADLOCK" over a ring that
the fix proves is spacious enough (both scratches fit by rebasing).
"""
import torch
from simpler.task_interface import ArgDirection as D
from simpler_setup import SceneTestCase, TaskArgsBuilder, Tensor, scene_test
SENTINEL = 42.0
INIT_VAL = -1.0
@scene_test(level=2, runtime="tensormap_and_ringbuffer")
class TestHeapEmptyRingRebase(SceneTestCase):
"""Draining a ring to a non-zero offset must not block a whole-span alloc."""
RTOL = 0
ATOL = 0
CALLABLE = {
"orchestration": {
"source": "kernels/orchestration/heap_empty_ring_rebase_orch.cpp",
"function_name": "aicpu_orchestration_entry",
"signature": [D.INOUT, D.INOUT],
},
"incores": [
{
"func_id": 0,
"name": "FILL_CONST",
"source": "kernels/aic/kernel_write_const.cpp",
"core_type": "aic",
"signature": [D.OUT],
},
{
"func_id": 1,
"name": "COPY_FIRST",
"source": "kernels/aic/kernel_copy_first.cpp",
"core_type": "aic",
"signature": [D.IN, D.INOUT],
},
],
}
CASES = [
{
"name": "EmptyRingRebase",
"platforms": ["a2a3sim", "a2a3"],
"config": {"aicpu_thread_num": 2, "block_dim": 1},
"params": {},
},
]
def generate_args(self, params):
y1 = torch.full((16,), INIT_VAL, dtype=torch.float32)
y2 = torch.full((16,), INIT_VAL, dtype=torch.float32)
return TaskArgsBuilder(
Tensor("y1", y1),
Tensor("y2", y2),
)
def compute_golden(self, args, params):
# FILL_CONST writes SENTINEL to each scratch tensor's [0]; COPY_FIRST
# copies that to Y1[0] / Y2[0]. Both iterations must complete.
args.y1[0] = SENTINEL
args.y2[0] = SENTINEL
if __name__ == "__main__":
SceneTestCase.run_module(__name__)
kernels/orchestration/heap_empty_ring_rebase_orch.cpp
/*
* Copyright (c) PyPTO Contributors.
* This program is free software, you can redistribute it and/or modify it under the terms and conditions of
* CANN Open Software License Agreement Version 2.0 (the "License").
* Please refer to the License for details. You may not use this file except in compliance with the License.
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
* See LICENSE in the root of the software repository for the full text of the License.
* -----------------------------------------------------------------------------------------------------------
*/
/**
* heap_empty_ring_rebase: minimal repro of the empty-ring wrap-reuse deadlock.
*
* One scope reuses a single S-byte scratch tensor across two iterations. The
* scope's ring is sized in [S, 2S): it holds exactly one S-byte tensor at a
* time. Iteration 1 allocates T1 (at heap offset 0), fills it, and a consumer
* copies T1[0] to external Y1 — fully draining the ring so top == tail == S,
* i.e. the ring is empty but parked at the non-zero offset S. Iteration 2's
* producer depends on iteration 1's consumer, so when T2 is allocated the ring
* is already empty; yet the S-byte request fits neither the tail arc
* (heap_size - S < S) nor the wrapped head arc (strict wrap guard tail > S is
* false at tail == S). A drained ring therefore refuses an allocation that
* fits its whole span.
*
* Args layout: [Y1, Y2] (external outputs the host verifies)
*/
#include <cstdint>
#include "pto_orchestration_api.h" // NOLINT(build/include_subdir)
#define FUNC_FILL_CONST 0
#define FUNC_COPY_FIRST 1
extern "C" {
__attribute__((visibility("default"))) PTO2OrchestrationConfig aicpu_orchestration_config(const L2TaskArgs &orch_args) {
(void)orch_args;
return PTO2OrchestrationConfig{
.expected_arg_count = 2,
};
}
__attribute__((visibility("default"))) void aicpu_orchestration_entry(const L2TaskArgs &orch_args) {
const Tensor &ext_Y1 = orch_args.tensor(0).ref();
const Tensor &ext_Y2 = orch_args.tensor(1).ref();
// One S-byte scratch = [1024, 256] float32 = 1 MiB. The scope ring is sized
// 1.5 MiB via PTO2_RING_HEAP, landing squarely in [S, 2S).
uint32_t scratch_shapes[2] = {1024, 256};
TensorCreateInfo scratch_ci(scratch_shapes, 2, DataType::FLOAT32);
// Iteration 1 in its own scope: produce T1 at heap offset 0, consume it into
// Y1, then close the scope so the ring reclaims T1 and drains to
// top == tail == S (empty, parked at the non-zero offset S).
PTO2_SCOPE() {
L0TaskArgs fill1;
fill1.add_output(scratch_ci);
TaskOutputTensors t1_outs = rt_submit_aic_task(FUNC_FILL_CONST, fill1);
const Tensor &t1 = t1_outs.get_ref(0);
L0TaskArgs copy1;
copy1.add_input(t1);
copy1.add_inout(ext_Y1);
rt_submit_aic_task(FUNC_COPY_FIRST, copy1);
}
// Iteration 2 in the next scope on the same ring: T2 is allocated after the
// ring is drained at offset S, so the S-byte request fits neither the tail
// arc nor the wrapped head arc even though the ring is entirely free.
PTO2_SCOPE() {
L0TaskArgs fill2;
fill2.add_output(scratch_ci);
TaskOutputTensors t2_outs = rt_submit_aic_task(FUNC_FILL_CONST, fill2);
const Tensor &t2 = t2_outs.get_ref(0);
L0TaskArgs copy2;
copy2.add_input(t2);
copy2.add_inout(ext_Y2);
rt_submit_aic_task(FUNC_COPY_FIRST, copy2);
}
}
} // extern "C"
kernels/aic/kernel_write_const.cpp
/*
* Copyright (c) PyPTO Contributors.
* This program is free software, you can redistribute it and/or modify it under the terms and conditions of
* CANN Open Software License Agreement Version 2.0 (the "License").
* Please refer to the License for details. You may not use this file except in compliance with the License.
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
* See LICENSE in the root of the software repository for the full text of the License.
* -----------------------------------------------------------------------------------------------------------
*/
/**
* Writes a fixed sentinel (42.0f) to args[0][0]. Used as the producer in the
* dummy_task scene tests so a downstream consumer can verify the dependency
* chain (producer -> ... -> dummy -> consumer) propagates the value intact.
*/
#include <cstdint>
#include <pto/pto-inst.hpp>
#include "tensor.h"
#ifndef __gm__
#define __gm__
#endif
#ifndef __aicore__
#define __aicore__ [aicore] // NOLINT(whitespace/braces)
#endif
#include "intrinsic.h"
#ifdef PTO_CPUSTUB_HPP
#define dcci(...) \
do { \
} while (0)
#endif
#ifndef SINGLE_CACHE_LINE
#define SINGLE_CACHE_LINE 0
#endif
#ifndef CACHELINE_OUT
#define CACHELINE_OUT 0
#endif
extern "C" __aicore__ void kernel_entry(__gm__ int64_t *args) {
__gm__ Tensor *out_tensor = reinterpret_cast<__gm__ Tensor *>(args[0]);
__gm__ float *out = reinterpret_cast<__gm__ float *>(out_tensor->buffer.addr) + out_tensor->start_offset;
out[0] = 42.0f;
dcci(&out[0], SINGLE_CACHE_LINE, CACHELINE_OUT);
}
kernels/aic/kernel_copy_first.cpp
/*
* Copyright (c) PyPTO Contributors.
* This program is free software, you can redistribute it and/or modify it under the terms and conditions of
* CANN Open Software License Agreement Version 2.0 (the "License").
* Please refer to the License for details. You may not use this file except in compliance with the License.
* THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
* INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
* See LICENSE in the root of the software repository for the full text of the License.
* -----------------------------------------------------------------------------------------------------------
*/
/**
* Consumer kernel for the dummy_task scene tests: copies args[0][0] -> args[1][0].
*
* If the dummy_task in the middle of the chain has correctly waited on the
* producer and notified this consumer, args[1][0] will equal the producer's
* sentinel (42.0f). If the dependency chain broke or the dummy somehow ran a
* kernel that clobbered args[0], the value will differ.
*/
#include <cstdint>
#include <pto/pto-inst.hpp>
#include "tensor.h"
#ifndef __gm__
#define __gm__
#endif
#ifndef __aicore__
#define __aicore__ [aicore] // NOLINT(whitespace/braces)
#endif
#include "intrinsic.h"
#ifdef PTO_CPUSTUB_HPP
#define dcci(...) \
do { \
} while (0)
#endif
#ifndef SINGLE_CACHE_LINE
#define SINGLE_CACHE_LINE 0
#endif
#ifndef CACHELINE_OUT
#define CACHELINE_OUT 0
#endif
extern "C" __aicore__ void kernel_entry(__gm__ int64_t *args) {
__gm__ Tensor *in_tensor = reinterpret_cast<__gm__ Tensor *>(args[0]);
__gm__ Tensor *out_tensor = reinterpret_cast<__gm__ Tensor *>(args[1]);
__gm__ float *in = reinterpret_cast<__gm__ float *>(in_tensor->buffer.addr) + in_tensor->start_offset;
__gm__ float *out = reinterpret_cast<__gm__ float *>(out_tensor->buffer.addr) + out_tensor->start_offset;
out[0] = in[0];
dcci(&out[0], SINGLE_CACHE_LINE, CACHELINE_OUT);
}
Relation to #429
Issue #429 (closed) also reported this try_bump_heap rejection at
tail == alloc_size, but proposed the opposite fix — relaxing the guard from
tail > alloc_size to >=. That relaxation is incorrect: permitting
tail == alloc_size places an allocation that makes top == tail, which this
allocator reads as empty (used == 0), reintroducing the full/empty ambiguity.
The strict > is therefore intentional — locked by
test_task_allocator.cpp::HeapWrapGuardRejectsTailEqualsAllocSize and
HeapLinearGapGuardRejectsExactFit. The correct fix keeps the strict > and
rebases a genuinely empty ring (top == tail, used == 0) to its base, as
shown above. #429's >= was never applied upstream — try_bump_heap still uses
strict > at HEAD 62840403 — and this empty-ring deadlock still reproduces
there. So this is not the same defect as #429's proposed change: same symptom and
function, opposite (and correct) fix, still unresolved at HEAD.
中文总结
try_bump_heap 在环排空但停在非零偏移、且 ring_size ∈ [S, 2S) 时,会拒绝一个本该装得下
整段的 S 分配(尾段 R-d<S 不够,回绕又被 strict > guard 挡),报 HEAP_RING_DEADLOCK——纯
分配器算术缺陷,空环没用满自己容量。最小 ST(一个 1 MiB scratch 在两个连续 scope 里复用,ring 压到
1.5 MiB)在 upstream main HEAD 62840403(及 fork point 8cdb306c)上死锁、在 https://github.com/Leaf-Salix/simpler/tree/fix/auto-scope-heapfree 修复分支 fd82ae69
上通过。修复=在 top==tail 失败路径
里 rebase 回 base;lib-free 回归测试见上方折叠块。
Platform
a2a3 (Ascend 910B/C hardware)
Runtime Variant
tensormap_and_ringbuffer
Description
PTO2TaskAllocator::try_bump_heap(
src/a2a3/runtime/tensormap_and_ringbuffer/runtime/pto_ring_buffer.h) fails anallocation of size
S(withS <= heap_size_) when the ring is alreadydrained (
heap_top_ == heap_tail_,used == 0) but parked at a non-zerooffset
d— even though the whole ring is free. Whenheap_size_ = Rlands in[S, 2S): the tail arc has onlyR - d < Sbytes, and wrapping to the head arcis rejected by the strict
>guard (d > Sis false atd == S). The allocatorthen latches
orch_error_code=2 HEAP_RING_DEADLOCKwith no live bytes in thering. This is a pure allocator-arithmetic defect — an empty ring failing to use
its own capacity — independent of any upper-layer workload.
Steps to Reproduce
Expected Behavior
A fully drained ring must accommodate any allocation with
S <= heap_size_.Iteration 2 should succeed and the test should pass with
Y1[0] == Y2[0] == 42.Actual Behavior
Iteration 2's allocation fails on the empty ring; the host reports:
(Host-side
507018 ACL_ERROR_RT_AICPU_EXCEPTION; device-classified reason =orch_error_code=2 HEAP_RING_DEADLOCK.)Git Commit ID
6284040
CANN Version
9.0.0
Driver Version
26.0.rc1
Host Platform
Linux (aarch64)
Additional Context
Fix corroboration (same ST file, same
PTO2_RING_HEAP, only the runtimediffers): the fixed branch passes with
Y1[0] == Y2[0] == 42(1 passed).Since the fix fits both 1 MiB scratches into the 1.5 MiB ring, the ring is
demonstrably large enough — upstream's failure is not a capacity shortfall but
the empty-ring arithmetic refusing an allocation that fits.
Root cause: the failure path of
try_bump_heaplacks an empty-ring rebase.When
top == tail(used == 0) is parked at a non-zero offset and neither arcfits, the span should be rebased to
heap_base_(
heap_top_ = alloc_size; heap_tail_ = 0;). The strict>wrap guard itself iscorrect (needed for the full/empty ambiguity); the missing case is the empty
ring.
Suggested fix — add to the
top >= tailfailure path oftry_bump_heap:lib-free regression test —
test_task_allocator.cpp::HeapEmptyRingRebasesForContiguousAllocReproduced-at-HEAD: dynamically reproduced on
mainHEAD62840403(reala2a3,
orch_error_code=2 HEAP_RING_DEADLOCK,1 failed) as well as on fork point8cdb306c. Consistently,pto_ring_buffer.h(containingtry_bump_heap) isbyte-identical across
8cdb306c..62840403— the 36 intervening commitstouch only
pto_orchestrator.cpp(dense-dep diagnostics #1448, markerconsolidation #1410, dispatch marker #1411, early-dispatch #1405), none of the
allocator logic.
Reproduction range & fix: reproduced across
8cdb306c..HEAD62840403, overwhich
pto_ring_buffer.his byte-identical — so this is not a regression fromthose commits. The introducing commit was not bisected; the missing empty-ring
branch in
try_bump_heapis a long-standing gap in the allocator, not a recentchange. Fixed on our fork by
fd82ae69("Fix: rebase a drained ring heap to itsbase for contiguous reuse", applied to all three runtimes).
Reproduction — full ST sources
Drop these into
tests/st/a2a3/tensormap_and_ringbuffer/heap_empty_ring_rebase/, thenrun the command in Steps to Reproduce. No
src/change is required.test_heap_empty_ring_rebase.pykernels/orchestration/heap_empty_ring_rebase_orch.cppkernels/aic/kernel_write_const.cppkernels/aic/kernel_copy_first.cppRelation to #429
Issue #429 (closed) also reported this
try_bump_heaprejection attail == alloc_size, but proposed the opposite fix — relaxing the guard fromtail > alloc_sizeto>=. That relaxation is incorrect: permittingtail == alloc_sizeplaces an allocation that makestop == tail, which thisallocator reads as empty (
used == 0), reintroducing the full/empty ambiguity.The strict
>is therefore intentional — locked bytest_task_allocator.cpp::HeapWrapGuardRejectsTailEqualsAllocSizeandHeapLinearGapGuardRejectsExactFit. The correct fix keeps the strict>andrebases a genuinely empty ring (
top == tail,used == 0) to its base, asshown above. #429's
>=was never applied upstream —try_bump_heapstill usesstrict
>at HEAD62840403— and this empty-ring deadlock still reproducesthere. So this is not the same defect as #429's proposed change: same symptom and
function, opposite (and correct) fix, still unresolved at HEAD.
中文总结
try_bump_heap在环排空但停在非零偏移、且ring_size ∈ [S, 2S)时,会拒绝一个本该装得下整段的
S分配(尾段R-d<S不够,回绕又被 strict>guard 挡),报HEAP_RING_DEADLOCK——纯分配器算术缺陷,空环没用满自己容量。最小 ST(一个 1 MiB scratch 在两个连续 scope 里复用,ring 压到
1.5 MiB)在 upstream
mainHEAD62840403(及 fork point8cdb306c)上死锁、在 https://github.com/Leaf-Salix/simpler/tree/fix/auto-scope-heapfree 修复分支fd82ae69上通过。修复=在
top==tail失败路径里 rebase 回 base;lib-free 回归测试见上方折叠块。