Skip to content

FDT: don't extend dedup across RAIDZ expansion - #18826

Open
Zaczero wants to merge 3 commits into
openzfs:masterfrom
Zaczero:fix/ddt-extend-raidz-geometry
Open

FDT: don't extend dedup across RAIDZ expansion#18826
Zaczero wants to merge 3 commits into
openzfs:masterfrom
Zaczero:fix/ddt-extend-raidz-geometry

Conversation

@Zaczero

@Zaczero Zaczero commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Motivation and Context

Summary. Fast dedup (FDT) and RAIDZ expansion both shipped in 2.3.0. Used
together they can produce a block pointer that silently loses the redundancy the
user paid for and — during a scrub or resilver — overwrites an adjacent block
on disk.
The root cause: a dedup entry born before an expansion can later be
extended with a DVA allocated after it, placing two RAIDZ stripe-width epochs
under the entry's single physical birth. RAIDZ uses that one birth to pick the
width for every DVA of the block, so it reads the newer DVA at the wrong
(older, wider) geometry. The fix never creates such a block (producer guard,
commit 3), fails cleanly on pools that already carry one (containment, commit 2),
and first hardens the retry path both rely on (commit 1). No on-disk format
change.

What has to happen for the bug. All three must hold:

  1. The pool uses fast dedup (feature@fast_dedup, 2.3.0+) and dedup is
    enabled on the dataset.
  2. A RAIDZ vdev was expanded (zpool attach) after some dedup data had
    already been written to it.
  3. Later, identical content is written at a higher copies value, so FDT
    extends the existing pre-expansion entry with a freshly allocated DVA on the
    now-wider RAIDZ vdev.

The result is one dedup entry with a DVA at the old width and a DVA at the new
width, both under a single pre-expansion birth.

Why it happens. RAIDZ makes geometry a function of time. After an expansion,
a block is read at the stripe width that was in effect when it was born,
selected from the BP's single physical birth:

zfs/module/zfs/vdev_raidz.c

Lines 2712 to 2713 in 9b7642d

uint64_t logical_width = vdev_raidz_get_logical_width(vdrz,
BP_GET_PHYSICAL_BIRTH(zio->io_bp));

That is correct only because, historically, every DVA in a BP was allocated in
one txg — one geometry epoch per BP. FDT extension breaks that assumption: when
an existing dedup entry gains copies, ddt_phys_extend() appends the newly
allocated DVAs but keeps the entry's original birth:

zfs/module/zfs/ddt.c

Lines 800 to 810 in 9b7642d

if (ddt_phys_birth(ddp, v) == 0) {
if (v == DDT_PHYS_FLAT) {
ddp->ddp_flat.ddp_phys_birth =
BP_GET_PHYSICAL_BIRTH(bp);
} else {
ddp->ddp_trad[v].ddp_phys_birth =
BP_GET_PHYSICAL_BIRTH(bp);
}
}
}

and every referencing BP is filled with that old birth:

zfs/module/zfs/ddt.c

Lines 708 to 729 in 9b7642d

ddt_bp_fill(const ddt_univ_phys_t *ddp, ddt_phys_variant_t v,
blkptr_t *bp, uint64_t txg)
{
ASSERT3U(txg, !=, 0);
ASSERT3U(v, <, DDT_PHYS_NONE);
uint64_t phys_birth;
const dva_t *dvap;
if (v == DDT_PHYS_FLAT) {
phys_birth = ddp->ddp_flat.ddp_phys_birth;
dvap = ddp->ddp_flat.ddp_dva;
} else {
phys_birth = ddp->ddp_trad[v].ddp_phys_birth;
dvap = ddp->ddp_trad[v].ddp_dva;
}
for (int d = 0; d < SPA_DVAS_PER_BP; d++)
bp->blk_dva[d] = dvap[d];
BP_SET_BIRTH(bp, txg, phys_birth);
}
/*

So the entry born before the expansion, extended with a post-expansion DVA,
yields a BP carrying two width epochs under one birth — and RAIDZ reads the newe
DVA at the older width.

What it causes. Two tiers:

  • The extended copy is read at the wrong geometry. The BP cannot represent the
    newer DVA's width epoch, so that copy generally fails checksum verification
    (small or layout-invariant blocks can escape the misparse, but the redundancy
    contract is broken either way). While the original copy is healthy this is
    masked — so the redundancy the user paid for is silently absent — until the
    original copy is lost, at which point the block is unreadable even though the
    newer copy's data is physically intact.
  • Worse, self-heal is destructive. An old-width map's footprint is always
    ≥ the new-width allocation (the nearby DEBUG-only
    VERIFY3U(asize_new, <=, asize)
    only checks that geometric direction — it never compares a map's footprint
    with the DVA's owned allocation, so no build detects the overrun). A scrub or
    resilver repairing the extended copy maps that oversized footprint and
    writes past the DVA's allocation, into an adjacent block. For a 128 KiB
    block on a RAIDZ1 expanded 3→4 at ashift=12, the map covers 48 sectors over
    a 44-sector allocation — a 16 KiB overrun.

A single per-DVA birth cannot be represented without an on-disk format change, s
the correct fix is to never create the mixed-epoch BP, and to contain repairs on
pools that already have one.

Detecting an affected pool. zdb -DDDDD <pool> shows a flat DDT entry with
one birth but two DVAs of different ASIZE
(e.g. DVA[0]=<...:30000> DVA[1]=<...:2c000>).

Workaround until patched. Avoid raising copies (or otherwise re-writing
identical content at a higher copies value) on RAIDZ pools that were expanded
after the data was first written; dedup=off avoids the producer entirely.

Provenance. Affected releases are 2.3.0 onward. The premise was noticed
during review of #18324 (that FDT can add copies while retaining one birth,
here and
here) and
correctly resolved for space accounting (free walks per-DVA ASIZE). This PR
completes that analysis for the I/O path, where the single per-BP birth does
select geometry and therefore mis-parses the new-width DVA.

Additional Details (optional read)

Description

Three commits, independently backportable:

1. zio: make chained DDT extension retry transactional — hardens the
existing extension-retry path, which must be correct before a second reason to
reject an extension is added. Fixes three latent defects that also affect the
shipped gang-mismatch reject: a shared rollback snapshot that could revert an
entry to a state keeping an uncommitted DVA or dropping a committed one (now
advanced only by each completed child's own BP, and cleared only by the last
lead); an unconditional alloc-throttle-queue decrement that underflowed for
synchronous children (now gated on ZIO_FLAG_ALLOC_THROTTLED); and an
intentional EAGAIN that reached spa_log_error()/zfs_ereport_post()
before being classified as a retry, logging a persistent error for a hole BP
(now consumed before error reporting). The retry classification keys on the
EAGAIN itself — an allocating logical dedup write — so it covers both
deliberate refusal sites (the allocation-stage gang refusal in
zio_write_gang_block() and the READY-stage rejects) with one consumer, and
it terminates because the retry monotonically disables dedup.

2. vdev_raidz: contain repair maps that exceed their DVA allocation
protects pools already carrying a malformed entry. When a birth-selected map's
footprint exceeds the matched DVA's owned ASIZE (vdev_raidz_map_exceeds_dva(),
gang headers compared against their width-invariant header extent), the whole
map is rejected with EIO in vdev_raidz_io_start() — for reads as well as
writes — before any column I/O. vdev_raidz_io_done() short-circuits a
rejected map, so no column reads, no
vdev_raidz_read_all()
re-issue, and no repair writes from a replacing/spare mirror below the RAIDZ
(which issues its own BP-less repairs)
are ever emitted. The bad copy fails cleanly instead of overrunning a
neighbor; other copies read normally. A legitimate block's implied footprint
never exceeds what its DVA owns (a gang child may own more than the final BP
implies), so it is never rejected. When the historical and physical widths
differ, a direct top-level dispatch whose BP matches no DVA of the addressed
vdev is likewise treated as malformed and rejected (fail closed), and the
rejection diagnostic reports the required birth-width ASIZE against the
DVA's owned ASIZE.

3. zio: prevent DDT extension across a RAIDZ expansion boundary — the
producer fix. At extension READY, if a newly allocated DVA would land on a
RAIDZ top vdev whose logical width differs between the entry's birth and the
allocation txg (vdev_raidz_same_logical_width()), reject the extension. The
write falls back to an ordinary non-dedup write — a fresh, coherent BP — via
the existing EAGAIN reexecute path, exactly as the gang-mismatch reject
already does:

zfs/module/zfs/zio.c

Lines 3753 to 3762 in 9b7642d

if (ddt_phys_is_gang(dde->dde_phys, v)) {
for (int i = 0; i < BP_GET_NDVAS(zio->io_bp); i++) {
dva_t *d = &zio->io_bp->blk_dva[i];
metaslab_group_alloc_decrement(zio->io_spa,
DVA_GET_VDEV(d), zio->io_allocator,
METASLAB_ASYNC_ALLOC, zio->io_size, zio);
}
zio->io_error = EAGAIN;
}

Same-epoch extensions, fresh entries (birth 0), traditional tables, and
non-RAIDZ tops are unaffected. No on-disk format change.

How Has This Been Tested?

Coverage is called out per commit, since one part (the containment) cannot be
exercised by a committed test — see below.

  • Commit 3 (producer guard) — new ZTS test raidz_expand_008_pos. Writes a
    copies=1 dedup block, expands RAIDZ1 3→4, crosses the completion marker, then
    requests copies=2 of the same content. On unpatched code the entry is
    extended across the width boundary (a flat entry with two different-ASIZE
    DVAs); with the fix it is not (the entry stays a single old-width DVA and the
    second file gets a fresh non-dedup BP), and a same-epoch control still extends
    normally. The test pins each DDT dump to the pool GUID and drains the
    FDT log (a bounded sync loop until both log headers are empty) before counting
    persistent DDT entries — the previous revision's form could inspect the wrong
    pool (cachefile=none with a bare zdb) and counted log-resident entries.
    The test was also driven red→green in a userspace libzpool harness before
    writing the ZTS form.
  • Commit 1 (retry hygiene). The errlog-suppression leg is covered by the same
    ZTS test: its closing No known data errors assertion follows a guard-triggered
    EAGAIN retry, so a spurious persistent error would fail it. The chained-lead
    rollback is verified by analysis (every interleaving) and exercised
    stochastically by zloop; like other multi-in-flight-lead fixes it has no
    deterministic committed test.
  • Commit 2 (containment) — verified in a userspace libzpool harness, no
    committed regression test.
    A deliberately crafted malformed entry (a
    new-width DVA spliced onto an old-birth entry) is read and rewritten through
    the real RAIDZ path. With the fix, both the read and the write return EIO
    with 0 leaf columns issued (measured via vdev_stat op counters), and a
    canary spanning every leaf byte the oversized old-width map reaches beyond the
    DVA is unchanged; with the containment removed, the read instead returns
    ECKSUM and issues 8 leaf columns, so the harness has teeth. I am flagging
    plainly that this commit has no committed regression test: once commit 3
    lands, the malformed on-disk state can no longer be produced through supported
    operations, and the only committed routes are a debug-only guard-bypass tunable
    (declined — production-code cost) or a pre-built malformed-pool image. I can add
    a malformed-pool-image ZTS as a follow-up if maintainers prefer; the harness
    patch and red/green logs are available.
  • Reproduction recipe (disposable file vdevs) available for maintainers.
  • Userland build clean (-Werror, debug); make cstyle, make commitcheck, and
    git diff --check clean; each commit builds standalone. I have not run the
    full ZFS Test Suite against a live kernel on this host; CI runs it.

Types of Changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Performance enhancement (non-breaking change which improves efficiency)
  • Code cleanup (non-breaking change which makes code smaller or more readable)
  • Quality assurance (non-breaking change which makes the code more robust against bugs)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Library ABI change (libzfs, libzfs_core, libnvpair and libzfsbootenv)
  • Documentation (a change to man pages or other documentation)

Checklist

@behlendorf behlendorf added the Status: Code Review Needed Ready for review and testing label Jul 21, 2026
@amotin

amotin commented Jul 21, 2026

Copy link
Copy Markdown
Member

@Zaczero I haven't looked inside, but CI looks really unhappy.

@Zaczero
Zaczero force-pushed the fix/ddt-extend-raidz-geometry branch from 9fdff66 to 4edf963 Compare July 21, 2026 17:59
When a fast-dedup (FDT) entry is written with more copies than it
currently holds, zio_ddt_write() issues a child write to allocate the
shortfall DVAs and, at READY, extends the entry via ddt_phys_extend().
A later write that wants still more copies adopts the in-flight lead as
its own child, forming a chain of leads that complete oldest-first.

Three latent defects in that path make it unsafe to add a second reason
to reject an extension at READY (see the following commits):

1. The chain shares a single rolling rollback point,
   dde_rollback_phys. On a successful child it was advanced with
   ddt_phys_copy() from the live dde_phys, which by then may already
   include a newer, not-yet-committed lead's DVAs; and on failure it was
   cleared unconditionally even while a later lead still needed it. A
   failed lead could then retain an uncommitted DVA or drop a committed
   one. Advance the rollback point with the completing child's own
   private BP (ddt_phys_extend(orig, v, zio->io_bp)); clear it only when
   this was the last lead (dde_lead_zio[p] == NULL).

2. zio_ddt_child_write_ready() unwound the per-metaslab-group
   allocation queue depth unconditionally on the gang-reject path, but
   that depth is only incremented for throttled async allocations. A
   synchronous or unthrottled child underflowed the counter. Gate the
   decrement on ZIO_FLAG_ALLOC_THROTTLED, which is set iff the
   increment happened.

3. An intentional EAGAIN (the dedup-to-plain-write fallback) reached
   spa_log_error()/zfs_ereport_post() in zio_done() before it was
   classified as a retry, logging a persistent on-disk data error and
   an ereport for a hole BP. Consume it after transforms, before error
   reporting. A deliberate extension-refusal EAGAIN has two producers
   -- zio_write_gang_block()'s allocation-stage refusal to gang a DDT
   extension child (a mixed gang/non-gang BP would be illegal) and the
   READY-stage refusal for an already-ganged live phys (joined by the
   RAIDZ geometry check in a following commit). Disable dedup, clear
   io_error, and reexecute as a plain write; the retry terminates
   because zp_dedup transitions monotonically to false. A backend
   EAGAIN can also take this path and gets one non-dedup retry before
   ordinary error handling.

These are pre-existing on the gang-extension path. No functional change
for the common single-lead extension.

Signed-off-by: Kamil Monicz <kamil@monicz.dev>
@Zaczero
Zaczero force-pushed the fix/ddt-extend-raidz-geometry branch from 4edf963 to 2b27d15 Compare July 21, 2026 18:04
A RAIDZ vdev selects each block's logical stripe width from the block
pointer's single physical birth (vdev_raidz_get_logical_width()). After
a raidz expansion, an older block reads at its birth-time width and its
recorded ASIZE always covers that (wider, more-parity) footprint, so
implied == owned.

If a block pointer instead names a DVA that was allocated at the new
(narrower) width but is stamped with an older physical birth -- which
fast-dedup extension across an expansion boundary can produce, and
which older FDT code could also write -- then the birth-selected map
covers more space than the DVA actually owns. A self-heal or resilver
of that copy would map that oversized footprint and write past the
DVA's allocation, into an adjacent block -- in any build: the nearby
DEBUG-only VERIFY (asize_new <= asize) only checks that width geometry
is monotonic in psize terms; nothing compares the implied footprint
against the DVA's owned allocation.

Detect the condition (vdev_raidz_io_exceeds_dva(): the birth-width
footprint exceeds the matched DVA's owned ASIZE, gang headers compared
against their constant header extent) and reject it with EIO in
vdev_raidz_io_start() -- for reads as well as writes -- before taking
the reflow rangelock or constructing a map. A zfs_dbgmsg breadcrumb
identifies the rejected RAIDZ vdev and geometry. vdev_raidz_io_done()
returns for the resulting NULL map, so no column reads, no
vdev_raidz_read_all() re-issue, and no repair writes from a
replacing/spare mirror below the RAIDZ are ever emitted. The bad copy
fails with a checksum/IO error (degraded redundancy) instead of
corrupting a neighbor; the other copies are unaffected and read
normally.

The classifier only fires for the exact malformed DVA: a legitimate
block's implied footprint never exceeds what its DVA owns (a gang
child may own more than the final BP implies), so it is never
rejected.

When the historical and physical widths differ, a direct top-level
dispatch whose BP matches no DVA of the addressed vdev is impossible;
treat it as the same malformed condition and reject (fail closed)
rather than assuming safety. The rejection diagnostic reports the
required (birth-width) ASIZE against the DVA's owned ASIZE.

Signed-off-by: Kamil Monicz <kamil@monicz.dev>
@Zaczero
Zaczero force-pushed the fix/ddt-extend-raidz-geometry branch from 2b27d15 to bc74d28 Compare July 21, 2026 19:12
A block pointer carries one physical birth for all of its DVAs, and
RAIDZ uses that birth to choose the stripe width to read every DVA at.
Ordinary allocation upholds the implied invariant -- every DVA in a BP
shares one geometry epoch -- because all of a BP's DVAs are allocated
in one txg.

Fast-dedup extension breaks it. ddt_phys_extend() appends newly
allocated DVAs to an existing flat phys while keeping the entry's
original ddp_phys_birth, and every referencing BP is filled with that
old birth. If the entry was born before a raidz expansion and the added
DVA is allocated after it, the two DVAs live in different width epochs
but share one birth. The BP cannot represent the newer DVA's geometry
epoch, so RAIDZ reads that copy at the wrong layout (a checksum error
in the common case), and -- absent the containment added separately --
a self-heal maps an oversized footprint that overruns the DVA's
allocation.

Reject the extension at READY when a newly allocated DVA would land on
a raidz top vdev whose logical width at the entry's birth differs from
its width at the allocation txg (vdev_raidz_same_logical_width()). The
write falls back to an ordinary non-dedup write -- a fresh, coherent BP
whose DVAs share one epoch -- via the existing EAGAIN reexecute path,
exactly as the gang-mismatch reject already does. Same-epoch
extensions, fresh entries (birth 0), traditional tables, and non-raidz
tops are unaffected. No on-disk format change.

The intentional EAGAIN is consumed in zio_done() into ZIO_POST_REEXECUTE
(dedup disabled, io_error cleared) rather than propagated as an error.

Evaluating both reject reasons under dde_io_lock also corrects an
existing quirk: a DDT child that fails allocation (e.g. ENOSPC) on a
ganged entry now propagates its real error instead of being overwritten
with EAGAIN, matching the non-gang path.

Add raidz_expand_008_pos: write a copies=1 dedup block, expand 3->4,
then request copies=2 of the same content; assert the entry is not
extended across the width boundary (it stays a single old-width DVA and
the second file gets a fresh non-dedup BP), while a same-epoch control
still extends normally. The test pins each DDT dump to the pool
GUID and drains the FDT log (a bounded sync loop until both log
headers are empty) before asserting persistent DDT entry counts.

Signed-off-by: Kamil Monicz <kamil@monicz.dev>
@Zaczero
Zaczero force-pushed the fix/ddt-extend-raidz-geometry branch from bc74d28 to 842f273 Compare July 22, 2026 09:18
@amotin

amotin commented Jul 23, 2026

Copy link
Copy Markdown
Member

@Zaczero I don't care what AI helps you, but next time you submit PR with 6 pages of description I will not read it. I have other things to do. You'll have to look for other reviewers. And good luck with that!

@Zaczero

Zaczero commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

@Zaczero I don't care what AI helps you, but next time you submit PR with 6 pages of description I will not read it. I have other things to do. You'll have to look for other reviewers. And good luck with that!

I specifically tried to be verbose and clear and structure it in ways that make finding key information fast. It's all sectioned off the standard template you use here.

https://github.com/openzfs/zfs/blob/master/.github%2FPULL_REQUEST_TEMPLATE.md

That one. I was on the assumption that on a project like zfs, and with such template structure, more explanation and reasoning is better.

@amotin

amotin commented Jul 23, 2026

Copy link
Copy Markdown
Member

I was on the assumption that on a project like zfs, and with such template structure, more explanation and reasoning is better.

The template is there to make people to write at least something. If you look around, nobody else aside of several new AI-powered individuals here write so much, simply because writing is supposed to be harder then reading, and only AI here is literally payed by the word. Are you saying you've written 6 pages of PR description plus 3 additional unique pages of commit messages by hands? ... Whatever. Please respect the reviewers.

@Zaczero

Zaczero commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

I was on the assumption that on a project like zfs, and with such template structure, more explanation and reasoning is better.

The template is there to make people to write at least something. If you look around, nobody else aside of several new AI-powered individuals here write so much, simply because writing is supposed to be harder then reading, and only AI here is literally payed by the word. Are you saying you've written 6 pages of PR description plus 3 additional unique pages of commit messages by hands? ... Whatever. Please respect the reviewers.

I don't write much by hand these days; I find that reading and verification are much more efficient. I am also starting to use more voice typing :-). I decided to be clear and verbose with my reasoning because ZFS is a production filesystem that is used by many. At least to me, a "freshie," this appears much more approachable, but I can see that it could be too verbose for vets. I think for reviewers, the most important section is just Motivation and Context, as the rest is mostly redundant and is there to provide extra, "just in case" info. Motivation and Context focuses on the full story: cause, resolution, workarounds, and impact—everything that seems like it should matter for PRs into ZFS (in my opinion). I additionally like to use GitHub-rendered code blocks because they enrich the story without needing to jump around the pages. Unfortunately they do increase the page size too.

@Zaczero

Zaczero commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

I have now added a collapsible section, so the default view should contain pretty much all that is necessary for a review, leaving optional things optional. In my taste, but it is slightly deviating from the PR template now.


I was thinking this, that the template could be improved, at least by removing the Description section, which feels redundant in favor of Motivation and Context. I cannot think of a reason why there are two of these sections to be filled in, and this would save everyone time.

Comment thread module/zfs/zio.c Outdated
Comment on lines +3755 to +3761
if (zio->io_flags & ZIO_FLAG_ALLOC_THROTTLED) {
for (int i = 0; i < BP_GET_NDVAS(zio->io_bp); i++) {
dva_t *d = &zio->io_bp->blk_dva[i];
metaslab_group_alloc_decrement(zio->io_spa,
DVA_GET_VDEV(d), zio->io_allocator,
METASLAB_ASYNC_ALLOC, zio->io_size, zio);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The change itself seems to make sense, but makes me wonder what this code is doing here in DDT at all, and why it is not in zio_ready(), near the metaslab_class_throttle_unreserve()?

It makes me thing that only this specific commit would worth a 3 separate PRs.

@amotin amotin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside of vdev_raidz_psize_to_asize() refactoring, which is nice but irrelevant, this second commit looks excessively verbose for what it really is -- a partial workaround for fixed extremely rare bug, that has to stay in the code forever. I don't very like the traversal over the DVAs list. And vdev_raidz_dva_extent_t definition just make me think about your 6 pages of PR description -- you should restrain yourself. ;)

Comment thread module/zfs/vdev_raidz.c
Comment on lines +2780 to +2783
if (extent == VDEV_RAIDZ_DVA_EXTENT_UNMATCHED) {
zfs_dbgmsg("%s: rejecting direct raidz "
"vdev %llu at offset %llx size %llu: "
"no BP DVA owns it",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say this worth assertion, not a dbgmsg. If it should not happen, then it should be asserted.

Comment thread module/zfs/zio.c
Comment on lines +3761 to +3763
if (vd->vdev_ops == &vdev_raidz_ops &&
!vdev_raidz_same_logical_width(vd, phys_birth,
zio->io_txg))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anything, I'd say this should be a vdev type method.

But by this time we already passed allocation throttling and allocated a space, just to find the issue, free it and restart again. While this should work, I'm thinking if it would be a less of a code pollution just to block the BP expansion if the RAIDZ expansion feature is active on the pool. I really don't know anybody using copies=2 on their pools, especially in combination with dedup and RAIDZ expansion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Status: Code Review Needed Ready for review and testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants