On 8/19/26 3:05 PM, Denis V. Lunev wrote:
> From: Denis V. Lunev <[email protected]>
> 
> A dirty image must be repaired before anything allocates a cluster in
> it. qcow2_do_open() does that, but only for a node that is writable
> from the start. A node opened read-only skips it, and nothing revisits
> the question once that node becomes writable, which block-commit does
> routinely: commit_active_start() and commit_start() reopen the base
> read-write for the duration of the job.
> 
> With lazy refcounts the on-disk refcount block then still accounts for
> the metadata clusters only, so the allocator restarts at the front of
> the image and hands out clusters that L2 entries point at. Two guest
> offsets end up sharing one host cluster. Nothing fails, the corrupt bit
> stays clear, and a clean close clears the dirty bit, so no later open
> repairs the image either. The bit also stays set for the whole writable
> session, so a node which is merely writable says nothing.
> 
> Refusing the reopen instead is simpler and keeps it atomic, but it
> leaves nowhere to go: the base belongs to a chain the VM has open, so
> the qemu-img check -r such an error would ask for cannot take the write
> lock it needs. The repair does the trick in most cases anyway.
> 
> Do the repair in qcow2_reopen_commit_post(), the earliest point where
> the node is writable. An inactive node is skipped: bdrv_activate() calls
> qcow2_do_open() again through qcow2_co_invalidate_cache().
> 
> commit_post cannot reject the reopen, so a failed repair leaves only
> what qcow2_signal_corruption() does, take the driver away from the node,
> rather than let writes alias live clusters. Return the error and skip
> the bitmaps.
>

Nit: qcow2_signal_corruption() also sends qapi event and writes corrupt
bit to the header, and we don't do it here.  AFAICT the code is right,
but this claim is a bit misleading.  Maybe clarify why no event should
be emitted.

Another nit: block-stream and change-backing file also seem to be doing
RO->RW reopen, but they aren't mentioned.  Should their docs also be
updated?

Andrey
> Signed-off-by: Denis V. Lunev <[email protected]>
> CC: Kevin Wolf <[email protected]>
> CC: Hanna Reitz <[email protected]>
> CC: Andrey Drobyshev <[email protected]>
> ---
>  block/qcow2.c              |  21 +++++++
>  qapi/block-core.json       |  16 +++++
>  tests/qemu-iotests/039     |  60 ++++++++++++++++++
>  tests/qemu-iotests/039.out |  36 +++++++++++
>  tests/qemu-iotests/040     | 122 +++++++++++++++++++++++++++++++++++++
>  tests/qemu-iotests/040.out |   4 +-
>  6 files changed, 257 insertions(+), 2 deletions(-)
> 
> diff --git a/block/qcow2.c b/block/qcow2.c
> index 553a94d003..e91523699f 100644
> --- a/block/qcow2.c
> +++ b/block/qcow2.c
> @@ -2147,8 +2147,29 @@ static void qcow2_reopen_commit(BDRVReopenState *state)
>  
>  static int qcow2_reopen_commit_post(BDRVReopenState *state, Error **errp)
>  {
> +    ERRP_GUARD();
> +    BDRVQcow2State *s = state->bs->opaque;
> +
>      GRAPH_RDLOCK_GUARD_MAINLOOP();
>  
> +    if (!bdrv_reopen_was_writable(state) && bdrv_is_writable(state->bs) &&
> +        (s->incompatible_features & QCOW2_INCOMPAT_DIRTY)) {
> +        BdrvCheckResult result = {0};
> +        int ret;
> +
> +        ret = bdrv_check(state->bs, &result, BDRV_FIX_ERRORS | 
> BDRV_FIX_LEAKS);
> +        if (ret < 0 || result.check_errors || !state->bs->drv) {
> +            ret = ret < 0 ? ret : -EIO;
> +            /* No write may reach an image whose refcounts are unaccounted */
> +            state->bs->drv = NULL;

Your commit says: "... failed repair leaves only what
qcow2_signal_corruption() does".

> +            error_setg_errno(errp, -ret, "Could not repair dirty image '%s'",
> +                             bdrv_get_device_or_node_name(state->bs));
> +            error_append_hint(errp, "The image is left dirty and this node "
> +                              "holds it open until the node is removed\n");
> +            return ret;
> +        }
> +    }
> +
>      if (state->flags & BDRV_O_RDWR) {
>          Error *local_err = NULL;
>  
> diff --git a/qapi/block-core.json b/qapi/block-core.json
> index 199efc1e00..9aec081f7b 100644
> --- a/qapi/block-core.json
> +++ b/qapi/block-core.json
> @@ -1891,6 +1891,11 @@
>  # size to match the size of the smaller top, you can safely truncate
>  # it yourself once the commit operation successfully completes.
>  #
> +# The base is opened read-write for the duration of the job.  A dirty
> +# qcow2 base is repaired first, which reads all of its metadata and
> +# holds up every other request while it runs.  The command fails if
> +# that repair does not succeed.
> +#
>  # @job-id: identifier for the newly-created block job.  If omitted,
>  #     the device name will be used.  (Since 2.7)
>  #
> @@ -4989,6 +4994,17 @@
>  # transaction, so if one of them fails then the whole transaction is
>  # cancelled.
>  #
> +# An error is also returned when a device cannot be used once it has
> +# been reopened.  Such a reopen is not undone, so an error does not
> +# always mean that nothing has changed.  A node the driver gave up on
> +# serves nothing at all: it keeps its image open, makes
> +# `query-named-block-nodes` fail for as long as it is in the graph,
> +# and `blockdev-del` removes it only once nothing refers to it.
> +#
> +# Reopening a dirty qcow2 image read-write repairs it first, which
> +# reads all of its metadata and holds up every other request while it
> +# runs.
> +#
>  # The command receives a list of block devices to reopen.  For each
>  # one of them, the top-level @node-name option (from
>  # `BlockdevOptions`) must be specified and is used to select the block
> diff --git a/tests/qemu-iotests/039 b/tests/qemu-iotests/039
> index 3d0c073d65..3f37c36ca8 100755
> --- a/tests/qemu-iotests/039
> +++ b/tests/qemu-iotests/039
> @@ -33,6 +33,7 @@ status=1    # failure is the default!
>  _cleanup()
>  {
>       _cleanup_test_img
> +     rm -f "$TEST_DIR/blkdebug.conf"
>  }
>  trap "_cleanup; exit \$status" 0 1 2 3 15
>  
> @@ -176,6 +177,65 @@ $QEMU_IO -c "write 0 512" "$TEST_IMG" | _filter_qemu_io
>  # The dirty bit must not be set
>  _qcow2_dump_header | grep incompatible_features
>  
> +echo
> +echo "== Reopening a dirty image read/write should repair it =="
> +
> +_make_test_img -o "compat=1.1,lazy_refcounts=on" $size
> +
> +_NO_VALGRIND \
> +$QEMU_IO -c "write -P 0x5a 0 512" \
> +         -c "sigraise $(kill -l KILL)" "$TEST_IMG" 2>&1 \
> +    | _filter_qemu_io
> +
> +# The dirty bit must be set
> +_qcow2_dump_header | grep incompatible_features
> +
> +# Without the repair this write would alias the cluster at offset 0
> +$QEMU_IO -r -c "reopen -w" \
> +            -c "write -P 0xb1 1M 512" \
> +            -c "read -P 0x5a 0 512" "$TEST_IMG" | _filter_qemu_io
> +
> +_check_test_img
> +
> +echo
> +echo "== A read/write reopen must not check the image =="
> +
> +_make_test_img -o "compat=1.1,lazy_refcounts=on" $size
> +
> +_NO_VALGRIND \
> +$QEMU_IO -c "write -P 0x5a 0 512" \
> +         -c "reopen -o l2-cache-size=1M" \
> +         -c "sigraise $(kill -l KILL)" "$TEST_IMG" 2>&1 \
> +    | _filter_qemu_io
> +
> +# The dirty bit must still be set, it belongs to the running session
> +_qcow2_dump_header | grep incompatible_features
> +
> +echo
> +echo "== A failed repair must fail the reopen =="
> +
> +_make_test_img -o "compat=1.1,lazy_refcounts=on" $size
> +
> +_NO_VALGRIND \
> +$QEMU_IO -c "write -P 0x5a 0 512" \
> +         -c "sigraise $(kill -l KILL)" "$TEST_IMG" 2>&1 \
> +    | _filter_qemu_io
> +
> +cat > "$TEST_DIR/blkdebug.conf" <<EOF
> +[inject-error]
> +event = "none"
> +iotype = "write"
> +errno = "5"
> +EOF
> +
> +# The repair cannot write, so the reopen itself must report the failure
> +$QEMU_IO -r -c "reopen -w" -c "read -P 0x5a 0 512" \
> +    "blkdebug:$TEST_DIR/blkdebug.conf:$TEST_IMG" 2>&1 \
> +    | _filter_testdir | _filter_qemu_io | _filter_generated_node_ids
> +
> +# The corrupt bit needs a write of its own, so the image is only left dirty
> +_qcow2_dump_header | grep incompatible_features
> +
>  echo
>  echo "== Creating an image file with lazy_refcounts=off =="
>  
> diff --git a/tests/qemu-iotests/039.out b/tests/qemu-iotests/039.out
> index ce8ee57721..cc6ca3ab95 100644
> --- a/tests/qemu-iotests/039.out
> +++ b/tests/qemu-iotests/039.out
> @@ -79,6 +79,42 @@ wrote 512/512 bytes at offset 0
>  512 bytes, X ops; XX:XX:XX.X (XXX YYY/sec and XXX ops/sec)
>  incompatible_features     []
>  
> +== Reopening a dirty image read/write should repair it ==
> +Formatting 'TEST_DIR/t.IMGFMT', fmt=IMGFMT size=134217728
> +wrote 512/512 bytes at offset 0
> +512 bytes, X ops; XX:XX:XX.X (XXX YYY/sec and XXX ops/sec)
> +./common.rc: Killed ( VALGRIND_QEMU="${VALGRIND_QEMU_IO}" _qemu_proc_exec 
> "${VALGRIND_LOGFILE}" "$QEMU_IO_PROG" $QEMU_IO_ARGS "$@" )
> +incompatible_features     [0]
> +ERROR cluster 5 refcount=0 reference=1
> +Rebuilding refcount structure
> +Repairing cluster 1 refcount=1 reference=0
> +Repairing cluster 2 refcount=1 reference=0
> +wrote 512/512 bytes at offset 1048576
> +512 bytes, X ops; XX:XX:XX.X (XXX YYY/sec and XXX ops/sec)
> +read 512/512 bytes at offset 0
> +512 bytes, X ops; XX:XX:XX.X (XXX YYY/sec and XXX ops/sec)
> +No errors were found on the image.
> +
> +== A read/write reopen must not check the image ==
> +Formatting 'TEST_DIR/t.IMGFMT', fmt=IMGFMT size=134217728
> +wrote 512/512 bytes at offset 0
> +512 bytes, X ops; XX:XX:XX.X (XXX YYY/sec and XXX ops/sec)
> +./common.rc: Killed ( VALGRIND_QEMU="${VALGRIND_QEMU_IO}" _qemu_proc_exec 
> "${VALGRIND_LOGFILE}" "$QEMU_IO_PROG" $QEMU_IO_ARGS "$@" )
> +incompatible_features     [0]
> +
> +== A failed repair must fail the reopen ==
> +Formatting 'TEST_DIR/t.IMGFMT', fmt=IMGFMT size=134217728
> +wrote 512/512 bytes at offset 0
> +512 bytes, X ops; XX:XX:XX.X (XXX YYY/sec and XXX ops/sec)
> +./common.rc: Killed ( VALGRIND_QEMU="${VALGRIND_QEMU_IO}" _qemu_proc_exec 
> "${VALGRIND_LOGFILE}" "$QEMU_IO_PROG" $QEMU_IO_ARGS "$@" )
> +ERROR cluster 5 refcount=0 reference=1
> +Rebuilding refcount structure
> +qemu-io: ERROR writing refblock: Input/output error
> +qemu-io: Could not repair dirty image 'NODE_NAME': Input/output error
> +The image is left dirty and this node holds it open until the node is removed
> +read failed: No medium found
> +incompatible_features     [0]
> +
>  == Creating an image file with lazy_refcounts=off ==
>  Formatting 'TEST_DIR/t.IMGFMT', fmt=IMGFMT size=134217728
>  wrote 512/512 bytes at offset 0
> diff --git a/tests/qemu-iotests/040 b/tests/qemu-iotests/040
> index 5c18e413ec..452c87f9cd 100755
> --- a/tests/qemu-iotests/040
> +++ b/tests/qemu-iotests/040
> @@ -951,6 +951,128 @@ class 
> TestCommitWithOverriddenBacking(iotests.QMPTestCase):
>          self.vm.qmp('block-job-complete', device='commit')
>          self.vm.event_wait('BLOCK_JOB_COMPLETED')
>  
> +QCOW2_INCOMPAT_FEATURES_OFFSET = 72
> +QCOW2_INCOMPAT_DIRTY = 1 << 0
> +
> +image_size = 4 * 1024 * 1024
> +dirty_base = os.path.join(iotests.test_dir, 'dirty-base.img')
> +mid = os.path.join(iotests.test_dir, 'dirty-mid.img')
> +top = os.path.join(iotests.test_dir, 'dirty-top.img')
> +
> +
> +class TestCommitDirtyBase(iotests.QMPTestCase):
> +    def setUp(self) -> None:
> +        if iotests.imgfmt != 'qcow2':
> +            self.case_skip('the dirty bit is a qcow2 feature')
> +        iotests.qemu_img_create('-f', iotests.imgfmt, '-o',
> +                                'compat=1.1,lazy_refcounts=on', dirty_base,
> +                                str(image_size))
> +        # Killing the process leaves the refcounts of the written cluster 
> stale
> +        iotests.qemu_io_popen('-t', 'writethrough',
> +                              '-c', 'write -P 0x5a 0 512',
> +                              '-c', 'sigraise 9', dirty_base).communicate()
> +        iotests.qemu_img_create('-f', iotests.imgfmt, '-b', dirty_base,
> +                                '-F', iotests.imgfmt, mid)
> +        iotests.qemu_img_create('-f', iotests.imgfmt, '-b', mid,
> +                                '-F', iotests.imgfmt, top)
> +        # The commit has to allocate for this, which is where the stale
> +        # refcounts hand out the cluster holding the data written above
> +        qemu_io('-c', 'write -P 0xb1 1M 512', mid)
> +
> +        self.vm = iotests.VM()
> +        self.vm.launch()
> +        self.vm.cmd('blockdev-add', driver='file', filename=dirty_base,
> +                    node_name='base-file')
> +
> +        self.assertEqual(self.incompatible_features(), QCOW2_INCOMPAT_DIRTY)
> +
> +    def tearDown(self) -> None:
> +        if self.vm.is_running():
> +            self.vm.shutdown()
> +        for image in (dirty_base, mid, top):
> +            os.remove(image)
> +
> +    def add_chain(self, base_file: str) -> None:
> +        self.vm.cmd('blockdev-add', driver=iotests.imgfmt, file=base_file,
> +                    node_name='base', read_only=True)
> +        self.vm.cmd('blockdev-add', driver='file', filename=mid,
> +                    node_name='mid-file')
> +        self.vm.cmd('blockdev-add', driver=iotests.imgfmt, file='mid-file',
> +                    node_name='mid', backing='base')
> +        self.vm.cmd('blockdev-add', driver='file', filename=top,
> +                    node_name='top-file')
> +        self.vm.cmd('blockdev-add', driver=iotests.imgfmt, file='top-file',
> +                    node_name='top', backing='mid')
> +
> +    def check_base(self) -> None:
> +        result = iotests.qemu_img_check(dirty_base)
> +        self.assertEqual(result['check-errors'], 0)
> +        self.assertEqual(result.get('corruptions', 0), 0)
> +        # Without the repair the commit would have aliased this cluster
> +        qemu_io('-c', 'read -P 0x5a 0 512', '-c', 'read -P 0xb1 1M 512',
> +                dirty_base)
> +
> +    def incompatible_features(self) -> int:
> +        with open(dirty_base, 'rb') as img:
> +            img.seek(QCOW2_INCOMPAT_FEATURES_OFFSET)
> +            return struct.unpack('>Q', img.read(8))[0]
> +
> +    def test_commit_repairs_base(self) -> None:
> +        self.add_chain('base-file')
> +
> +        self.vm.cmd('block-commit', job_id='job0', device='top',
> +                    top_node='mid', base_node='base')
> +        self.wait_until_completed(drive='job0')
> +
> +        self.vm.shutdown()
> +        self.assertEqual(self.incompatible_features(), 0)
> +        self.check_base()
> +
> +    def test_active_commit_repairs_base(self) -> None:
> +        self.add_chain('base-file')
> +
> +        # Without top-node the whole chain commits, through
> +        # commit_active_start() rather than commit_start()
> +        self.vm.cmd('block-commit', job_id='job0', device='top',
> +                    base_node='base')
> +        self.complete_and_wait(drive='job0')
> +
> +        self.vm.shutdown()
> +        self.assertEqual(self.incompatible_features(), 0)
> +        self.check_base()
> +
> +    def test_failed_repair_fails_the_commit(self) -> None:
> +        self.vm.cmd('blockdev-add', driver='blkdebug', image='base-file',
> +                    node_name='base-blkdebug',
> +                    inject_error=[{'event': 'none', 'iotype': 'write',
> +                                   'errno': 5}])
> +        self.add_chain('base-blkdebug')
> +
> +        result = self.vm.qmp('block-commit', job_id='job0', device='top',
> +                             top_node='mid', base_node='base')
> +        self.assert_qmp(result, 'error/class', 'GenericError')
> +        self.assertIn("Could not repair dirty image 'base'",
> +                      result['error']['desc'])
> +
> +        # The base is left in the graph, and nothing can be queried while
> +        # it is there
> +        result = self.vm.qmp('query-named-block-nodes', flat=True)
> +        self.assert_qmp(result, 'error/desc', 'Block device base is ejected')
> +
> +        # It only goes away once nothing refers to it
> +        result = self.vm.qmp('blockdev-del', node_name='base')
> +        self.assert_qmp(result, 'error/desc',
> +                        "Node 'base' is busy: node is used as backing hd of "
> +                        "'mid'")
> +
> +        # Marking the image corrupt needs a write of its own, which fails too
> +        self.assertEqual(self.incompatible_features(), QCOW2_INCOMPAT_DIRTY)
> +
> +        # Nothing can use the base any more, and that is what is reported
> +        result = self.vm.qmp('block-commit', job_id='job1', device='top',
> +                             top_node='mid', base_node='base')
> +        self.assert_qmp(result, 'error/desc', 'Device has no medium')
> +
>  if __name__ == '__main__':
>      iotests.main(supported_fmts=['qcow2', 'qed'],
>                   supported_protocols=['file'])
> diff --git a/tests/qemu-iotests/040.out b/tests/qemu-iotests/040.out
> index 1bb1dc5f0e..f3cbf73a01 100644
> --- a/tests/qemu-iotests/040.out
> +++ b/tests/qemu-iotests/040.out
> @@ -1,5 +1,5 @@
> -.................................................................
> +....................................................................
>  ----------------------------------------------------------------------
> -Ran 65 tests
> +Ran 68 tests
>  
>  OK


Reply via email to