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.

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;
+            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
-- 
2.53.0


Reply via email to