This is an automated email from the ASF dual-hosted git repository.

anton-vinogradov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/ignite.git


The following commit(s) were added to refs/heads/master by this push:
     new d30df8c0e80 IGNITE-28976 [ducktests] Add optional demo breakpoints to 
pause a running scenario for inspection (#13470)
d30df8c0e80 is described below

commit d30df8c0e80207808962a0ea080acb05f8c16c91
Author: Maksim Davydov <[email protected]>
AuthorDate: Tue Sep 8 15:23:03 2026 +0300

    IGNITE-28976 [ducktests] Add optional demo breakpoints to pause a running 
scenario for inspection (#13470)
---
 .gitignore                                         |   1 +
 modules/ducktests/README.md                        |  66 ++-
 .../requirements-dev.txt => checks/__init__.py}    |   4 -
 .../services/__init__.py}                          |   4 -
 .../services/network_group/__init__.py}            |   4 -
 .../support/__init__.py}                           |   4 -
 .../tests/checks/support/demo_pause_control.py     | 108 ++++
 .../tests/checks/support/ducktape_doubles.py       | 116 +++++
 .../utils/__init__.py}                             |   4 -
 .../tests/checks/utils/check_ignite_spec.py        |  16 +-
 .../utils/pause/__init__.py}                       |   4 -
 .../tests/checks/utils/pause/check_banner.py       | 131 +++++
 .../tests/checks/utils/pause/check_control.py      | 139 ++++++
 .../tests/checks/utils/pause/check_control_dir.py  | 225 +++++++++
 .../tests/checks/utils/pause/check_selector.py     |  94 ++++
 .../tests/checks/utils/pause/check_timeout.py      |  72 +++
 modules/ducktests/tests/docker/demo_console.py     | 195 ++++++++
 .../ducktests/tests/docker/requirements-dev.txt    |   1 +
 modules/ducktests/tests/docker/run_tests.sh        |   9 +
 .../tests/ignitetest/services/mdc/mdc_cluster.py   |  30 ++
 .../ignitetest/services/network_group/manager.py   |  32 +-
 .../tests/mdc/partition_resilience_test.py         |  10 +
 .../tests/ignitetest/utils/ignite_test.py          |  45 ++
 modules/ducktests/tests/ignitetest/utils/pause.py  | 541 +++++++++++++++++++++
 .../tests/ignitetest/utils/pause_control.py        | 250 ++++++++++
 modules/ducktests/tests/setup.py                   |   2 +-
 modules/ducktests/tests/tox.ini                    |   6 +
 27 files changed, 2074 insertions(+), 39 deletions(-)

diff --git a/.gitignore b/.gitignore
index 1e684823db1..8ab90f323cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -90,6 +90,7 @@ CMakeSettings.json
 
 #Ducktape
 /results
+/.ducktests-demo
 .ducktape
 *.pyc
 /tests/venv
diff --git a/modules/ducktests/README.md b/modules/ducktests/README.md
index ab29714ac8e..2c4d2b690aa 100644
--- a/modules/ducktests/README.md
+++ b/modules/ducktests/README.md
@@ -132,9 +132,8 @@ To locally simulate validation matrices across distinct 
target runtimes (e.g., P
    pyenv install 3.9
    pyenv shell 3.8 3.9
    ```
-3. Install `tox` and run the validation suite:
+3. Run the validation suite:
    ```bash
-   pip install tox
    tox
    tox -r -e codestyle,py3
    ```
@@ -239,6 +238,69 @@ You can target specific cross-product version 
compatibility combinations inside
 --global-json '{"safepoint_log_enabled": true}'
 ```
 
+### Demo Mode (Breakpoints)
+
+Scenarios can be frozen at named breakpoints, so a cluster can be shown to an 
audience in exactly that state and then resumed. Ducktape runs the test inside 
`ducker01` with stdin closed, so the keyboard lives in a second terminal.
+
+Terminal 1 - run the test with the `demo_pause` global:
+```bash
+./docker/run_tests.sh -n 12 -gj '{"demo_pause": "*"}' \
+  -t 
./ignitetest/tests/mdc/partition_resilience_test.py::MdcPartitionResilienceTest.test_mdc_cluster_partition_resilience
+```
+
+Terminal 2 - drive the breakpoints:
+```bash
+python docker/demo_console.py
+```
+
+At every breakpoint the console prints the step, the elapsed time, every 
service node with its liveness, the live network state (netem delays and 
partition drops as they are actually applied), and ready-to-paste commands for 
entering nodes and reading their logs and configs. `Enter` continues, `c` runs 
the rest unattended, `a` aborts the test.
+
+While paused the cluster keeps running and any network impairment stays in 
effect, so nodes can be inspected freely:
+```bash
+./docker/ducker-ignite ssh ducker03
+docker exec ducker03 bash -c "tail -n 50 /mnt/service/logs/ignite*.log"
+docker exec ducker03 cat /mnt/service/config/ignite-config.xml
+```
+
+The console is optional - the test communicates through files under 
`<repository root>/.ducktests-demo`, which is shared with the host by the same 
bind mount that carries the repository into the containers. Each file is a 
simple signal - its presence is the command, its content (where any) is the 
data:
+
+| File | Meaning |
+|------|---------|
+| `paused.txt` | The banner of the breakpoint currently held, human readable. |
+| `paused.json` | The same banner plus run metadata, for the console to read. |
+| `continue-<N>` | Resume breakpoint `N`, where `N` is the number shown in the 
banner's `PAUSED N` line. The banner itself prints the exact `touch` command to 
paste. |
+| `continue-all` | Resume and skip every remaining breakpoint. |
+| `abort` | Fail the test and tear down the cluster. |
+
+```bash
+cat .ducktests-demo/paused.txt      # read the banner of the breakpoint 
currently held
+touch .ducktests-demo/continue-3    # resume breakpoint 3 (the number matches 
PAUSED 3 in the banner)
+touch .ducktests-demo/continue-all  # resume and skip the remaining breakpoints
+touch .ducktests-demo/abort         # fail the test and tear down
+```
+
+Both sides find that directory on their own, so by default nothing has to be 
configured. `demo_pause_dir` overrides it - but the two sides name the same 
directory differently, since the test runs inside `ducker01`, where the 
repository is mounted at `/opt/ignite-dev`, while the console runs on the host. 
Override it and the console has to be pointed at the host side of it:
+```bash
+./docker/run_tests.sh -gj '{"demo_pause": "*", "demo_pause_dir": 
"/opt/ignite-dev/.demo"}' -t ./ignitetest/tests/<some_test.py>
+
+python docker/demo_console.py -d .demo
+```
+
+Breakpoints are added to a test with `self.pause("name", mdc, net)` and cost 
nothing when the global is absent, which is how they stay in the tests without 
affecting CI. Run one test at a time in demo mode (no `--max-parallel`): a 
single control directory holds one breakpoint at a time.
+
+A held test reports nothing back to ducktape, which kills a session it has 
heard nothing from for `--test-runner-timeout` (30 minutes by default) - and 
that budget is measured from the construction of the test (setup included), 
which is deliberately conservative: ducktape's kill timer is actually reset by 
the last client event before the pause, which fires after setup, so the real 
window is longer. Breakpoints therefore auto-continue while the runner is still 
waiting, shortening themselv [...]
+```bash
+./docker/run_tests.sh -n 12 --test-runner-timeout 7200000 \
+  -gj '{"demo_pause": "*", "demo_pause_timeout_sec": 1800}' \
+  -t ./ignitetest/tests/mdc/partition_resilience_test.py
+```
+
+| Global Parameter Key | Definition | Example Configuration |
+|---------------------|------------|----------------------|
+| **demo_pause** | Which breakpoints stop the scenario. Absent or `false` 
disables them all (the default); `true` or `"*"` stops at every one; a list or 
comma separated string stops only at the named ones, matched case 
insensitively. | ```{"demo_pause": "split-brain,healed"}``` |
+| **demo_pause_timeout_sec** | How long one breakpoint may hold the scenario 
before it resumes on its own. Default is 600, and it is capped by what is left 
of `--test-runner-timeout`. | ```{"demo_pause_timeout_sec": 1800}``` |
+| **demo_pause_dir** | Control directory shared with the host, named as the 
test sees it - inside the containers the repository is `/opt/ignite-dev`. 
Default is `<repository root>/.ducktests-demo`; anything else has to be passed 
to the console as well, with `-d` and the host path. | ```{"demo_pause_dir": 
"/opt/ignite-dev/.demo"}``` |
+
 ### Security Settings
 ```bash
 # Enable built-in authentication overrides
diff --git a/modules/ducktests/tests/docker/requirements-dev.txt 
b/modules/ducktests/tests/checks/__init__.py
similarity index 94%
copy from modules/ducktests/tests/docker/requirements-dev.txt
copy to modules/ducktests/tests/checks/__init__.py
index f868e43d674..ec2014340d7 100644
--- a/modules/ducktests/tests/docker/requirements-dev.txt
+++ b/modules/ducktests/tests/checks/__init__.py
@@ -12,7 +12,3 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
--r requirements.txt
-pytest==6.2.5
-flake8==6.1.0
diff --git a/modules/ducktests/tests/docker/requirements-dev.txt 
b/modules/ducktests/tests/checks/services/__init__.py
similarity index 94%
copy from modules/ducktests/tests/docker/requirements-dev.txt
copy to modules/ducktests/tests/checks/services/__init__.py
index f868e43d674..ec2014340d7 100644
--- a/modules/ducktests/tests/docker/requirements-dev.txt
+++ b/modules/ducktests/tests/checks/services/__init__.py
@@ -12,7 +12,3 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
--r requirements.txt
-pytest==6.2.5
-flake8==6.1.0
diff --git a/modules/ducktests/tests/docker/requirements-dev.txt 
b/modules/ducktests/tests/checks/services/network_group/__init__.py
similarity index 94%
copy from modules/ducktests/tests/docker/requirements-dev.txt
copy to modules/ducktests/tests/checks/services/network_group/__init__.py
index f868e43d674..ec2014340d7 100644
--- a/modules/ducktests/tests/docker/requirements-dev.txt
+++ b/modules/ducktests/tests/checks/services/network_group/__init__.py
@@ -12,7 +12,3 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
--r requirements.txt
-pytest==6.2.5
-flake8==6.1.0
diff --git a/modules/ducktests/tests/docker/requirements-dev.txt 
b/modules/ducktests/tests/checks/support/__init__.py
similarity index 94%
copy from modules/ducktests/tests/docker/requirements-dev.txt
copy to modules/ducktests/tests/checks/support/__init__.py
index f868e43d674..ec2014340d7 100644
--- a/modules/ducktests/tests/docker/requirements-dev.txt
+++ b/modules/ducktests/tests/checks/support/__init__.py
@@ -12,7 +12,3 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
--r requirements.txt
-pytest==6.2.5
-flake8==6.1.0
diff --git a/modules/ducktests/tests/checks/support/demo_pause_control.py 
b/modules/ducktests/tests/checks/support/demo_pause_control.py
new file mode 100644
index 00000000000..97d5d31b676
--- /dev/null
+++ b/modules/ducktests/tests/checks/support/demo_pause_control.py
@@ -0,0 +1,108 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+The host side of a demo breakpoint, for checks that drive one.
+
+A held breakpoint blocks the thread that reached it, so everything the host 
does - reading the
+published banner, dropping a resume file - has to happen from another one 
while the check
+itself sits inside :meth:`ignitetest.utils.pause.DemoPause.pause`.
+
+The protocol itself is spoken through 
:class:`ignitetest.utils.pause_control.ControlDir`, the
+same class the test and ``docker/demo_console.py`` use - a check that hand 
rolled the file
+names would stop checking the protocol and start checking its own copy of it.
+"""
+
+import threading
+import time
+from contextlib import contextmanager
+
+from ignitetest.utils.pause import DEMO_PAUSE_TIMEOUT_SEC, DemoPause
+from ignitetest.utils.pause_control import ControlDir
+
+from checks.support.ducktape_doubles import FakeLogger
+
+# Stands for the test a breakpoint was reached in; breakpoints report it to 
the host.
+TEST_NAME = "check.CheckPause.check_something"
+
+# Far longer than the fraction of a second a check actually holds a breakpoint 
for, and far
+# shorter than the framework's own default: a resume that never arrives has to 
fail the check
+# that expected it rather than hold the suite for ten minutes.
+RESUME_TIMEOUT_SEC = 30
+
+
+def new_demo_pause(control_dir, started_at=None, runner_timeout_sec=None, 
**test_globals):
+    """
+    :return: A DemoPause over the given control directory, logging into a 
FakeLogger its
+             ``logger`` attribute hands back to the check.
+    """
+    test_globals.setdefault(DEMO_PAUSE_TIMEOUT_SEC, RESUME_TIMEOUT_SEC)
+
+    return DemoPause(FakeLogger(), test_globals, TEST_NAME, 
control_dir=str(control_dir),
+                     started_at=started_at, 
runner_timeout_sec=runner_timeout_sec)
+
+
+def resume_with(control_dir, name, delay_sec=.05):
+    """
+    Creates a resume file from another thread, the way the host does while the 
test blocks.
+    """
+    control = ControlDir(control_dir)
+
+    timer = threading.Timer(delay_sec, lambda: control.resume(name))
+    timer.daemon = True
+    timer.start()
+
+
+@contextmanager
+def published_status(control_dir, resume=None, timeout_sec=30):
+    """
+    Reads the published breakpoint while the check blocks on it, the way the 
host console
+    does, and optionally resumes it.
+
+    Polls for the file rather than reading it once after a fixed delay: a 
breakpoint that is
+    only held for a fraction of a second - which is what these checks hold 
them for - would
+    otherwise be a race against the machine the checks happen to run on.
+
+    :param resume: Name of the resume file to create once the breakpoint has 
been read, None
+           to leave it held.
+    :return: A dict, empty on entry and filled with the published breakpoint 
by the time the
+             block is left.
+    """
+    control = ControlDir(control_dir)
+    published = {}
+
+    def read():
+        deadline = time.monotonic() + timeout_sec
+
+        while time.monotonic() < deadline:
+            status = control.read_status()
+
+            if status is not None:
+                published.update(status)
+
+                break
+
+            time.sleep(.01)
+
+        if resume:
+            control.resume(resume)
+
+    reader = threading.Thread(target=read, daemon=True)
+    reader.start()
+
+    try:
+        yield published
+    finally:
+        reader.join(timeout_sec)
diff --git a/modules/ducktests/tests/checks/support/ducktape_doubles.py 
b/modules/ducktests/tests/checks/support/ducktape_doubles.py
new file mode 100644
index 00000000000..7a8621e5831
--- /dev/null
+++ b/modules/ducktests/tests/checks/support/ducktape_doubles.py
@@ -0,0 +1,116 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Stand-ins for the ducktape objects a test is handed - a logger, nodes, 
services and the
+registry they are collected in - for checks of framework code that only reads 
them.
+
+Each double is as poor as the real thing is at the point of use, so that a 
check fails on
+code reaching for more than a test actually offers it.
+"""
+
+from types import SimpleNamespace
+
+from ignitetest.services.utils.path import IgnitePathAware
+
+
+class FakeLogger:
+    """
+    Collects what the code under check would have logged.
+    """
+    def __init__(self):
+        self.messages = []
+
+    def info(self, msg):
+        """Records an info message."""
+        self.messages.append(msg)
+
+    def warn(self, msg):
+        """Records a warning."""
+        self.messages.append(msg)
+
+    debug = info
+    error = warn
+
+
+def fake_nodes(*hostnames):
+    """
+    :return: Nodes carrying the account attributes that ducktape's do.
+    """
+    return [SimpleNamespace(account=SimpleNamespace(hostname=host, 
externally_routable_ip=host))
+            for host in hostnames]
+
+
+class FakeService:
+    """
+    Stands in for a non-Ignite service of the test registry, e.g. a zookeeper 
one: it carries
+    paths of its own, which code following the Ignite services must not hand 
out for Ignite
+    nodes.
+    """
+    log_dir = "/mnt/service/zk-logs"
+    config_file = "/mnt/service/zookeeper.properties"
+
+    def __init__(self, *hostnames):
+        self.nodes = fake_nodes(*hostnames)
+
+    def who_am_i(self, node):
+        """Names the node the way a ducktape service does."""
+        return f"{self.__class__.__name__}-{node.account.hostname}"
+
+
+class FakeIgniteService(IgnitePathAware):
+    """
+    Stands in for an Ignite service, with the real path layout behind it.
+    """
+    def __init__(self, *hostnames):
+        self.nodes = fake_nodes(*hostnames)
+
+    def who_am_i(self, node):
+        """Names the node the way a ducktape service does."""
+        return f"{self.__class__.__name__}-{node.account.hostname}"
+
+    @property
+    def product(self):
+        return "ignite-dev"
+
+    @property
+    def globals(self):
+        return {}
+
+
+class FakeBrokenService:
+    """
+    Stands in for a service that can no longer answer for the nodes it still 
holds, the way a
+    ducktape one does once a node has been freed from it: ``who_am_i`` goes 
through ``idx()``,
+    which raises for a node the service does not own.
+    """
+    def __init__(self, *hostnames):
+        self.nodes = fake_nodes(*hostnames)
+
+    def who_am_i(self, node):
+        """Fails the way a ducktape service does for a node it does not own."""
+        raise RuntimeError(f"Could not find node {node}")
+
+
+class FakeRegistry:
+    """
+    Stands in for ducktape's ServiceRegistry, which is what a test hands to 
the framework: it
+    is iterable and nothing else, so code reading it may not index it or ask 
it for a length.
+    """
+    def __init__(self, *services):
+        self._services = services
+
+    def __iter__(self):
+        return iter(self._services)
diff --git a/modules/ducktests/tests/docker/requirements-dev.txt 
b/modules/ducktests/tests/checks/utils/__init__.py
similarity index 94%
copy from modules/ducktests/tests/docker/requirements-dev.txt
copy to modules/ducktests/tests/checks/utils/__init__.py
index f868e43d674..ec2014340d7 100644
--- a/modules/ducktests/tests/docker/requirements-dev.txt
+++ b/modules/ducktests/tests/checks/utils/__init__.py
@@ -12,7 +12,3 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
--r requirements.txt
-pytest==6.2.5
-flake8==6.1.0
diff --git a/modules/ducktests/tests/checks/utils/check_ignite_spec.py 
b/modules/ducktests/tests/checks/utils/check_ignite_spec.py
index a3c8e521049..b60fce333e1 100644
--- a/modules/ducktests/tests/checks/utils/check_ignite_spec.py
+++ b/modules/ducktests/tests/checks/utils/check_ignite_spec.py
@@ -16,6 +16,7 @@
 """
 Checks Spec class that describes config and command line to start Ignite-aware 
service.
 """
+import os
 from unittest.mock import Mock
 
 import pytest
@@ -90,10 +91,15 @@ def 
check_boolean_options__go_after_default_ones_and_overwrite_them__if_passed_v
 
 def 
check_colon_options__go_after_default_ones_and_overwrite_them__if_passed_via_jvm_opt(service):
     service.log_dir = "/default-path"
+
+    # The default is built with os.path.join, which follows the control 
machine rather than the
+    # node - so the expectation is joined the same way instead of being 
written out, or the check
+    # would only hold where the separator happens to be "/".
+    default_gc_log = ("-Xlog:gc*=debug,gc+stats*=debug,gc+ergo*=debug:"
+                      f"{os.path.join(service.log_dir, 
'gc.log')}:uptime,time,level,tags")
+
     spec = IgniteApplicationSpec(service, 
jvm_opts=["-Xlog:gc:/some-non-default-path/gc.log"])
+
     assert "-Xlog:gc:/some-non-default-path/gc.log" in spec.jvm_opts
-    assert 
"-Xlog:gc*=debug,gc+stats*=debug,gc+ergo*=debug:/default-path/gc.log:uptime,time,level,tags"
 \
-           in spec.jvm_opts
-    assert spec.jvm_opts.index("-Xlog:gc:/some-non-default-path/gc.log") > \
-           spec.jvm_opts.index(
-               
"-Xlog:gc*=debug,gc+stats*=debug,gc+ergo*=debug:/default-path/gc.log:uptime,time,level,tags")
+    assert default_gc_log in spec.jvm_opts
+    assert spec.jvm_opts.index("-Xlog:gc:/some-non-default-path/gc.log") > 
spec.jvm_opts.index(default_gc_log)
diff --git a/modules/ducktests/tests/docker/requirements-dev.txt 
b/modules/ducktests/tests/checks/utils/pause/__init__.py
similarity index 94%
copy from modules/ducktests/tests/docker/requirements-dev.txt
copy to modules/ducktests/tests/checks/utils/pause/__init__.py
index f868e43d674..ec2014340d7 100644
--- a/modules/ducktests/tests/docker/requirements-dev.txt
+++ b/modules/ducktests/tests/checks/utils/pause/__init__.py
@@ -12,7 +12,3 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-
--r requirements.txt
-pytest==6.2.5
-flake8==6.1.0
diff --git a/modules/ducktests/tests/checks/utils/pause/check_banner.py 
b/modules/ducktests/tests/checks/utils/pause/check_banner.py
new file mode 100644
index 00000000000..d32443388de
--- /dev/null
+++ b/modules/ducktests/tests/checks/utils/pause/check_banner.py
@@ -0,0 +1,131 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Checks the banner a held demo breakpoint renders: how long the scenario has 
been running, the
+nodes it is made of, and the commands offered for looking into them.
+"""
+
+import os
+import time
+
+from ignitetest.utils.pause import ALL, DemoPause, _resume_command
+from ignitetest.utils.pause_control import ControlDir, repo_root
+
+from checks.support.demo_pause_control import new_demo_pause, published_status
+from checks.support.ducktape_doubles import FakeBrokenService, 
FakeIgniteService, FakeRegistry, FakeService
+
+
+def check_elapsed_is_counted_from_test_start(tmp_path):
+    """
+    Check that the banner counts from the start of the test rather than from 
the first
+    breakpoint: the setup phase of a multi-node scenario is minutes long, and 
a demo that
+    reports t+00:00 after it hides exactly the part worth showing.
+    """
+    demo = new_demo_pause(tmp_path, started_at=time.monotonic() - 600, 
demo_pause=ALL, demo_pause_timeout_sec=.3)
+
+    with published_status(tmp_path) as published:
+        demo.pause("split-brain")
+
+    assert published["elapsed_sec"] >= 600
+    assert any("t+10:00 since test start" in line for line in 
published["banner"])
+
+
+def check_banner_is_rendered_from_the_service_registry(tmp_path):
+    """
+    Check that the banner is built by iterating the services alone: what a 
test passes is
+    ducktape's ServiceRegistry, which supports nothing else.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause=ALL, demo_pause_timeout_sec=.3)
+
+    with published_status(tmp_path) as published:
+        demo.pause("split-brain", 
services=FakeRegistry(FakeService("ducker02"), FakeIgniteService("ducker03")))
+
+    banner = "\n".join(published["banner"]).replace("\\", "/")
+
+    assert "FakeService-ducker02" in banner
+    assert "FakeIgniteService-ducker03" in banner
+    assert "/mnt/service/logs/ignite*.log" in banner, "the hints must still 
follow the Ignite service"
+    assert "ducker02 ducker03" in banner
+
+
+def check_a_service_that_cannot_answer_is_degraded_not_raised(tmp_path):
+    """
+    Check that a service which cannot answer for its nodes costs the banner 
those lines and
+    nothing more. A breakpoint only observes the cluster, so one that throws 
while rendering
+    would fail the scenario at exactly the point the demo was added to show.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause=ALL, demo_pause_timeout_sec=.3)
+
+    with published_status(tmp_path) as published:
+        demo.pause("split-brain",
+                   services=FakeRegistry(FakeBrokenService("ducker02"), 
FakeIgniteService("ducker03")))
+
+    banner = "\n".join(published["banner"])
+
+    assert "FakeBrokenService-ducker02" in banner, "the node must still be 
named, without asking its service"
+    assert "RuntimeError" in banner, "and what could not be read must say so"
+    assert "FakeIgniteService-ducker03" in banner, "a service after the broken 
one must still be listed"
+    assert "ducker02 ducker03" in banner, "and every node must still be 
offered by the hints"
+
+
+def check_the_resume_command_is_named_where_it_will_be_typed():
+    """
+    Check that the command a breakpoint offers is one its reader can actually 
run. The banner
+    is rendered inside ducker01, where the repository is mounted at 
/opt/ignite-dev, and the
+    console prints it verbatim on the host, where that path does not exist - 
so the shared
+    repository root is the only anchor the two sides have in common.
+    """
+    inside = ControlDir(os.path.join(repo_root(), ".ducktests-demo"))
+
+    command = _resume_command(inside, 3)
+
+    assert command == "touch .ducktests-demo/continue-3   (from the repository 
root)"
+    assert repo_root() not in command, "an absolute path here names a 
directory the reader may not have"
+
+
+def check_the_resume_command_falls_back_to_the_whole_path():
+    """
+    Check that a control directory outside the repository is still named in 
full: there is no
+    shared anchor left to make it relative to, so a whole path is the honest 
answer.
+    """
+    outside = ControlDir(os.path.join(os.path.dirname(repo_root()), 
"elsewhere", "demo"))
+
+    command = _resume_command(outside, 3)
+
+    assert command.endswith(os.path.join("elsewhere", "demo", "continue-3"))
+    assert "from the repository root" not in command
+
+
+def check_hints_follow_the_ignite_services():
+    """
+    Check that the copy-pasteable commands name the Ignite paths even when a 
service of
+    another kind was registered first, as the zookeeper discovery scenarios do.
+    """
+    # noinspection PyProtectedMember
+    hints = "\n".join(DemoPause._hints_section([FakeService("ducker02"),  # 
pylint: disable=protected-access
+                                                
FakeIgniteService("ducker03")]))
+
+    # The service paths come from os.path.join, which follows the control 
machine rather than
+    # the nodes - a check that runs on Windows would otherwise see its 
separators.
+    hints = hints.replace("\\", "/")
+
+    assert "/mnt/service/config/ignite-config.xml" in hints
+    assert "/mnt/service/logs/ignite*.log" in hints
+    assert "zookeeper.properties" not in hints
+    assert "zk-logs" not in hints
+
+    # Every node is still offered, whichever service it belongs to.
+    assert "ducker02 ducker03" in hints
diff --git a/modules/ducktests/tests/checks/utils/pause/check_control.py 
b/modules/ducktests/tests/checks/utils/pause/check_control.py
new file mode 100644
index 00000000000..e84c83478a6
--- /dev/null
+++ b/modules/ducktests/tests/checks/utils/pause/check_control.py
@@ -0,0 +1,139 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Checks the file protocol a demo breakpoint and the host speak over the control 
directory:
+what a held breakpoint publishes, what resumes it, and what is swept up 
afterwards.
+"""
+
+import os
+
+import pytest
+
+from ignitetest.utils.pause import ALL
+from ignitetest.utils.pause_control import ABORT, CONTINUE_ALL, STATUS_JSON, 
STATUS_TXT, continue_file
+
+from checks.support.demo_pause_control import TEST_NAME, new_demo_pause, 
published_status, resume_with
+
+
+def check_publishes_and_consumes_status(tmp_path):
+    """
+    Check the published breakpoint - what the host reads - and that the test 
cleans it up
+    once resumed, so a stale banner never outlives the pause it describes.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause=True)
+
+    with published_status(tmp_path, resume=continue_file(1)) as published:
+        demo.pause("split-brain", services=[])
+
+    assert published["seq"] == 1
+    assert published["run"] == demo.run
+    assert published["name"] == "split-brain"
+    assert published["test"] == TEST_NAME
+    assert any("PAUSED 1   split-brain" in line for line in 
published["banner"])
+
+    for leftover in (STATUS_JSON, STATUS_TXT, continue_file(1)):
+        assert not os.path.exists(str(tmp_path / leftover)), leftover
+
+
+def check_continue_all_skips_the_rest(tmp_path):
+    """
+    Check that continue-all resumes the current breakpoint and disables every 
later one, so
+    a demo can be cut short without restarting the scenario.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause=ALL)
+
+    resume_with(tmp_path, CONTINUE_ALL)
+
+    demo.pause("split-brain")
+
+    assert demo.seq == 1
+    assert not demo.enabled
+
+    demo.pause("healed")
+
+    assert demo.seq == 1, "breakpoints after continue-all must not stop the 
scenario"
+    assert not os.path.exists(str(tmp_path / CONTINUE_ALL))
+
+
+def check_abort_fails_the_test(tmp_path):
+    """
+    Check that abort ends the scenario through an assertion, so ducktape tears 
the cluster
+    down instead of leaving it running.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause=ALL)
+
+    resume_with(tmp_path, ABORT)
+
+    with pytest.raises(AssertionError, match="split-brain"):
+        demo.pause("split-brain")
+
+    assert not os.path.exists(str(tmp_path / ABORT))
+    assert not os.path.exists(str(tmp_path / STATUS_JSON))
+
+
+def 
check_a_control_directory_that_cannot_be_made_does_not_fail_the_scenario(tmp_path):
+    """
+    Check that a control directory which cannot even be created costs the demo 
and nothing
+    else. It is a bind mount of the host repository, so it can be read only or 
owned by
+    another user - neither of which says anything about the cluster under 
test, and a
+    breakpoint must not be what turns a passing run red.
+    """
+    # A file where the directory should go: the portable way to make 
os.makedirs fail.
+    (tmp_path / "in-the-way").write_text("not a directory", encoding="utf-8")
+
+    demo = new_demo_pause(tmp_path / "in-the-way" / "control", demo_pause=ALL)
+
+    demo.pause("split-brain")
+
+    assert demo.seq == 0, "the scenario must have carried straight on"
+    assert not demo.enabled, "and the later breakpoints must not try it again"
+    assert any("control directory cannot be used" in msg for msg in 
demo.logger.messages), demo.logger.messages
+
+
+def check_a_breakpoint_that_cannot_be_published_is_skipped(tmp_path):
+    """
+    Check the same where the directory exists but the banner cannot be 
written. Blocking would
+    be no better than raising here: nothing reached the host, so there would 
be nothing on
+    screen to resume, and the scenario would sit there for the whole timeout.
+    """
+    # A directory where the banner goes makes the write fail wherever these 
checks run. The
+    # sweep cannot remove it either, so it is still in the way when the 
breakpoint publishes.
+    os.mkdir(str(tmp_path / STATUS_TXT))
+
+    demo = new_demo_pause(tmp_path, demo_pause=ALL)
+
+    demo.pause("split-brain")
+
+    assert not demo.enabled
+    assert not os.path.exists(str(tmp_path / STATUS_JSON)), "half a breakpoint 
must not be left published"
+    assert any("control directory cannot be used" in msg for msg in 
demo.logger.messages), demo.logger.messages
+
+
+def check_stale_resume_file_is_cleared(tmp_path):
+    """
+    Check that a resume file left by a previous run does not skip the first 
breakpoint of
+    this one - the control directory outlives a test, its contents must not.
+    """
+    open(str(tmp_path / continue_file(1)), "w").close()
+    open(str(tmp_path / STATUS_TXT), "w").close()
+
+    demo = new_demo_pause(tmp_path, demo_pause=ALL, demo_pause_timeout_sec=.3)
+
+    demo.pause("split-brain")
+
+    assert demo.seq == 1
+    assert any("timed out" in msg for msg in demo.logger.messages), \
+        "the stale file must have been cleared, leaving the breakpoint to time 
out"
diff --git a/modules/ducktests/tests/checks/utils/pause/check_control_dir.py 
b/modules/ducktests/tests/checks/utils/pause/check_control_dir.py
new file mode 100644
index 00000000000..ea00c45c11d
--- /dev/null
+++ b/modules/ducktests/tests/checks/utils/pause/check_control_dir.py
@@ -0,0 +1,225 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Checks the control directory itself - the files a paused test and the host 
exchange, and the
+polling that waits for them - with no breakpoint on top of it.
+"""
+
+import os
+import time
+
+from ignitetest.utils.pause_control import ABORT, CONTINUE_ALL, ControlDir, 
STATUS_JSON, STATUS_TXT, continue_file
+
+
+def check_consuming_a_file_removes_it(tmp_path):
+    """
+    Check that consuming a control file reports it and removes it: the test is 
the only party
+    that deletes what the host wrote, so consuming one is what acknowledges it.
+    """
+    control = ControlDir(tmp_path)
+
+    assert not control.consume(ABORT), "nothing was written yet"
+
+    control.resume(ABORT)
+
+    assert control.exists(ABORT)
+    assert control.consume(ABORT)
+    assert not control.exists(ABORT), "a consumed file must not be left for 
the next breakpoint"
+    assert not control.consume(ABORT)
+
+
+def check_sweep_spares_a_held_breakpoint(tmp_path):
+    """
+    Check the asymmetry the two sides need: both clear the resume files an 
earlier run left,
+    but only the test drops the banner as well. The console is just as likely 
to have been
+    started against a test that is already holding one.
+    """
+    control = ControlDir(tmp_path)
+
+    control.publish(["PAUSED 1   split-brain"], {"seq": 1})
+    control.resume(continue_file(3))
+    control.resume(CONTINUE_ALL)
+
+    control.sweep()
+
+    assert not control.exists(continue_file(3))
+    assert not control.exists(CONTINUE_ALL)
+    assert control.read_status() is not None, "the console must not withdraw a 
breakpoint it did not hold"
+
+    control.sweep(status=True)
+
+    assert control.read_status() is None
+    assert not control.exists(STATUS_TXT)
+
+
+def check_sweeping_a_directory_that_is_not_there(tmp_path):
+    """
+    Check that sweeping is safe before anything has been created: without the 
global no
+    breakpoint ever makes the directory, and the console may well be started 
first.
+    """
+    control = ControlDir(tmp_path / "never-created")
+
+    control.sweep(status=True)
+
+    assert control.read_status() is None
+    assert not os.path.exists(control.path), "sweeping must not create what it 
was asked to clean"
+
+
+def check_sweep_clears_what_an_interrupted_write_left(tmp_path):
+    """
+    Check that the temporary name a write goes through is swept too, whichever 
file it
+    belonged to: a sweep is the only thing that ever visits the directory 
without knowing
+    what it expects to find, so it must also be the thing that cleans such a 
leftover.
+    """
+    control = ControlDir(tmp_path)
+
+    for interrupted in (continue_file(1), CONTINUE_ALL, ABORT, STATUS_TXT, 
STATUS_JSON):
+        control.write(interrupted + ".tmp", "")
+
+    control.sweep(status=True)
+
+    assert os.listdir(control.path) == []
+
+
+def check_sweep_spares_what_the_protocol_cannot_create(tmp_path):
+    """
+    Check that a sweep removes only names the protocol itself can leave: 
matching a fixed
+    name by prefix would take a foreign file that merely starts like one of 
its own.
+    """
+    control = ControlDir(tmp_path)
+
+    foreign = ["abort-note.txt", "paused.txt.bak", "resumed.json", "stray.tmp"]
+
+    for name in foreign:
+        control.write(name, "")
+
+    control.sweep(status=True)
+
+    assert sorted(os.listdir(control.path)) == sorted(foreign)
+
+
+def check_a_resume_file_lands_without_a_temporary(tmp_path):
+    """
+    Check that resuming creates the file and nothing besides: a resume file is 
empty, so an
+    atomic replace would buy nothing and leave a transient behind for the 
sweep to know about.
+    """
+    control = ControlDir(tmp_path)
+
+    control.resume(ABORT)
+
+    assert os.listdir(control.path) == [ABORT]
+
+
+def check_publishing_round_trips(tmp_path):
+    """
+    Check that what is published is what a reader gets back, and that 
withdrawing it leaves
+    nothing of either file behind.
+    """
+    control = ControlDir(tmp_path)
+
+    control.publish(["PAUSED 1   split-brain", "  test  some.Test"], {"seq": 
1, "name": "split-brain"})
+
+    assert control.read_status() == {"seq": 1, "name": "split-brain"}
+
+    with open(control.file(STATUS_TXT), encoding="utf-8") as file:
+        assert file.read() == "PAUSED 1   split-brain\n  test  some.Test\n"
+
+    control.clear_status()
+
+    assert control.read_status() is None
+    assert not control.exists(STATUS_TXT)
+    assert not control.exists(STATUS_JSON)
+
+
+def check_unreadable_status_reads_as_nothing_published(tmp_path):
+    """
+    Check that a status file caught half written reads as "not paused" rather 
than raising:
+    the host polls this in a loop and simply comes back.
+    """
+    control = ControlDir(tmp_path)
+
+    control.write(STATUS_JSON, '{"seq": 1')
+
+    assert control.read_status() is None
+
+
+def check_awaiting_takes_the_file_that_arrived(tmp_path):
+    """
+    Check the wait ends on the file that appears, and hands back which one it 
was.
+    """
+    control = ControlDir(tmp_path)
+
+    control.resume(continue_file(1))
+
+    assert control.await_any([ABORT, continue_file(1)], 5) == continue_file(1)
+    assert not control.exists(continue_file(1)), "the wait must consume what 
ended it"
+
+
+def check_awaiting_honours_the_order_it_was_given(tmp_path):
+    """
+    Check that the first name wins when several are already there: abort has 
to beat a
+    continue that landed in the same interval, or a demo would be resumed 
instead of ended.
+    """
+    control = ControlDir(tmp_path)
+
+    control.resume(CONTINUE_ALL)
+    control.resume(ABORT)
+
+    assert control.await_any([ABORT, CONTINUE_ALL], 5) == ABORT
+    assert control.exists(CONTINUE_ALL), "only the file that ended the wait 
may be consumed"
+
+
+def check_awaiting_gives_up(tmp_path):
+    """
+    Check that a wait nobody answers ends on its own rather than holding the 
scenario until
+    ducktape kills it.
+    """
+    control = ControlDir(tmp_path)
+
+    started_at = time.monotonic()
+
+    assert control.await_any([ABORT], .3) is None
+    assert time.monotonic() - started_at >= .3, "it must have waited for what 
it was given"
+
+
+def check_awaiting_ticks_without_being_told_how_often(tmp_path):
+    """
+    Check that a caller who wants to hear that the wait is still running need 
not also pick an
+    interval - asking for one and getting a TypeError out of a held breakpoint 
would be a poor
+    way to find out that the two arguments go together.
+    """
+    control = ControlDir(tmp_path)
+
+    ticks = []
+
+    assert control.await_any([ABORT], .6, tick=ticks.append) is None
+    assert ticks, "a wait longer than one poll must report itself"
+
+
+def check_awaiting_reports_that_it_is_still_waiting(tmp_path):
+    """
+    Check that the caller is ticked while the wait runs, which is how a held 
breakpoint says
+    in the test log that it is paused rather than stuck - without this class 
having to know
+    what a log is.
+    """
+    control = ControlDir(tmp_path)
+
+    ticks = []
+
+    assert control.await_any([ABORT], 1.2, tick=ticks.append, tick_sec=.01) is 
None
+    assert ticks, "a wait longer than the tick interval must report itself"
+    assert all(0 < left <= 1.2 for left in ticks), ticks
+    assert ticks == sorted(ticks, reverse=True), "each tick must report less 
time left than the last"
diff --git a/modules/ducktests/tests/checks/utils/pause/check_selector.py 
b/modules/ducktests/tests/checks/utils/pause/check_selector.py
new file mode 100644
index 00000000000..f01c5685626
--- /dev/null
+++ b/modules/ducktests/tests/checks/utils/pause/check_selector.py
@@ -0,0 +1,94 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Checks which demo breakpoints stop a scenario: how the ``demo_pause`` global 
is read, and
+which of the breakpoints in the test it then selects.
+"""
+
+import os
+
+from ignitetest.utils.pause import ALL, parse_selector
+from ignitetest.utils.pause_control import continue_file
+
+from checks.support.demo_pause_control import new_demo_pause, resume_with
+
+
+def check_selector_parsing():
+    """
+    Check that every shape the demo_pause global can arrive in is understood: 
-g passes it as
+    a string, -gj as whatever the json holds.
+    """
+    for disabled in (None, False, "", "false", "off", "0", [], "  "):
+        assert parse_selector(disabled) is None, disabled
+
+    for every in (True, "*", "all", "true", "ON", "1"):
+        assert parse_selector(every) == ALL, every
+
+    assert parse_selector("split-brain") == {"split-brain"}
+    assert parse_selector("split-brain, healed ,") == {"split-brain", "healed"}
+    assert parse_selector(["split-brain", "healed"]) == {"split-brain", 
"healed"}
+
+    # The global is typed by hand, the names live in the test source - the two 
meet case insensitively.
+    assert parse_selector("Split-Brain, HEALED") == {"split-brain", "healed"}
+    assert parse_selector(["Split-Brain"]) == {"split-brain"}
+
+
+def check_names_are_matched_case_insensitively(tmp_path):
+    """
+    Check that a breakpoint is found however the global spells it.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause="Split-Brain")
+
+    resume_with(tmp_path, continue_file(1))
+
+    demo.pause("split-brain")
+
+    assert demo.seq == 1, "the global must not have to repeat the case of the 
name in the test"
+
+
+def check_disabled_leaves_no_trace(tmp_path):
+    """
+    Check that without the global a breakpoint is a plain return: it must not 
block, and it
+    must not even create the control directory, since every test carries 
breakpoints in CI.
+    """
+    control_dir = tmp_path / "control"
+
+    demo = new_demo_pause(control_dir)
+
+    assert not demo.enabled
+
+    demo.pause("split-brain")
+
+    assert not os.path.exists(str(control_dir))
+    assert demo.seq == 0
+
+
+def check_selected_breakpoints_only(tmp_path):
+    """
+    Check that only the named breakpoints stop the scenario.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause="split-brain")
+
+    demo.pause("cluster-up")
+    demo.pause("healed")
+
+    assert demo.seq == 0, "an unnamed breakpoint must not stop the scenario"
+
+    resume_with(tmp_path, continue_file(1))
+
+    demo.pause("split-brain")
+
+    assert demo.seq == 1
diff --git a/modules/ducktests/tests/checks/utils/pause/check_timeout.py 
b/modules/ducktests/tests/checks/utils/pause/check_timeout.py
new file mode 100644
index 00000000000..e47b922281f
--- /dev/null
+++ b/modules/ducktests/tests/checks/utils/pause/check_timeout.py
@@ -0,0 +1,72 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Checks how long a demo breakpoint may hold a scenario: the timeout the demo 
asks for, and
+ducktape's --test-runner-timeout budget that only ever shortens it.
+"""
+
+import os
+
+from ignitetest.utils.pause import ALL, RUNNER_TIMEOUT_MARGIN_SEC
+from ignitetest.utils.pause_control import STATUS_JSON
+
+from checks.support.demo_pause_control import new_demo_pause, published_status
+
+
+def check_timeout_resumes_on_its_own(tmp_path):
+    """
+    Check that a forgotten breakpoint gives up rather than holding the 
scenario until
+    ducktape kills it.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause=ALL, demo_pause_timeout_sec=.3)
+
+    demo.pause("split-brain")
+
+    assert demo.seq == 1
+    assert demo.enabled, "a timed out breakpoint must not disable the later 
ones"
+    assert not os.path.exists(str(tmp_path / STATUS_JSON))
+
+
+def check_timeout_stays_within_the_runner_budget(tmp_path):
+    """
+    Check that a breakpoint gives up while ducktape's runner is still waiting: 
it hears
+    nothing from a paused test, and killing the client takes the whole session 
down instead
+    of just cutting the demo short. The requested timeout only ever shrinks.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause=ALL, 
demo_pause_timeout_sec=3600,
+                          runner_timeout_sec=RUNNER_TIMEOUT_MARGIN_SEC + .3)
+
+    demo.pause("split-brain")
+
+    assert demo.seq == 1
+    assert any("timed out" in msg for msg in demo.logger.messages), \
+        "the breakpoint must not outsit the runner budget it was given"
+    assert any("--test-runner-timeout" in msg for msg in 
demo.logger.messages), \
+        "shortening a breakpoint must say what to raise to keep it"
+
+
+def check_timeout_is_left_alone_within_the_runner_budget(tmp_path):
+    """
+    Check that the budget only ever caps the requested timeout - a demo that 
fits must be
+    held for exactly as long as it asked for.
+    """
+    demo = new_demo_pause(tmp_path, demo_pause=ALL, demo_pause_timeout_sec=.3, 
runner_timeout_sec=1800)
+
+    with published_status(tmp_path) as published:
+        demo.pause("split-brain")
+
+    assert published["timeout_sec"] == .3
+    assert not any("--test-runner-timeout" in msg for msg in 
demo.logger.messages)
diff --git a/modules/ducktests/tests/docker/demo_console.py 
b/modules/ducktests/tests/docker/demo_console.py
new file mode 100644
index 00000000000..0decc8c5c5e
--- /dev/null
+++ b/modules/ducktests/tests/docker/demo_console.py
@@ -0,0 +1,195 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Host side of the demo breakpoints - run it in a second terminal, next to the 
one running
+``run_tests.sh``, when a test is started with the ``demo_pause`` global:
+
+    ./docker/run_tests.sh -gj '{"demo_pause": "*"}' -t 
./ignitetest/tests/<some_test.py>
+
+    python docker/demo_console.py
+
+Ducktape runs the test with stdin on /dev/null inside the ``ducker01`` 
container, so this is
+where the keyboard lives. The console itself is deliberately dumb: the test 
renders the
+banner and this only prints it and writes back a resume file. Everything it 
does can be done
+by hand instead - ``cat .ducktests-demo/paused.txt``, then ``touch 
.ducktests-demo/continue-<N>``
+where ``<N>`` is the number shown in the banner's ``PAUSED N`` line.
+
+Standard library only: it runs on the host, outside the ducktests virtualenv.
+"""
+
+import argparse
+import importlib.util
+import os
+import sys
+import time
+
+# Speak the protocol through the framework's own ControlDir rather than 
restating it here:
+# the two sides of a shared directory have to agree file for file, and a 
second copy of it
+# is a second thing to keep in step.
+#
+# The host has no ducktape and no installed ignitetest, so the module is 
loaded by path -
+# importing ignitetest.utils.pause_control would pull in the package __init__ 
chain and its
+# ducktape imports. pause_control itself is standard library only, which is 
what makes it
+# loadable like this; ignitetest.utils.pause, which holds what the files mean, 
is not.
+_TESTS_DIR = 
os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), 
os.pardir))
+_PAUSE_CONTROL_PY = os.path.join(_TESTS_DIR, "ignitetest", "utils", 
"pause_control.py")
+
+_SPEC = importlib.util.spec_from_file_location("ignitetest_pause_control", 
_PAUSE_CONTROL_PY)
+pause = importlib.util.module_from_spec(_SPEC)
+_SPEC.loader.exec_module(pause)
+
+POLL_SEC = .3
+
+KEYS = """
+  [Enter] continue        [c] continue, skipping the rest        [a] abort the 
test
+  [q] leave the console (the test stays paused)
+"""
+
+
+def breakpoint_key(status):
+    """
+    :return: What identifies the published breakpoint. Not the sequence number 
on its own:
+             that one is per test, so it restarts at 1 for every test of a 
session, and a
+             run that died while paused leaves behind a banner numbered like a 
live one.
+    """
+    return status.get("run"), status.get("seq")
+
+
+def clear_stale(control):
+    """
+    Removes resume files left behind by an earlier run, which would otherwise 
skip the first
+    breakpoint of this one. The test clears them too, on its side, at its 
first breakpoint.
+
+    Only ever done while nothing is published: a console is just as likely to 
be started
+    against a test that is already holding a breakpoint, and a resume file 
that was written
+    for that one - by hand, or by a console that has just been closed - is the 
host's answer
+    to it rather than a leftover. The published breakpoint itself is left 
alone either way,
+    a stale banner is told apart by its run id.
+
+    :return: Whether the sweep was performed.
+    """
+    if control.read_status() is not None:
+        return False
+
+    control.sweep()
+
+    return True
+
+
+def prompt(control, seq):
+    """
+    Asks what to do with the breakpoint that is currently published.
+
+    :return: False when the console should stop, True to wait for the next 
breakpoint.
+    """
+    while True:
+        try:
+            answer = input("  > ").strip().lower()
+        except EOFError:
+            return False
+
+        if answer in ("", "n", "next"):
+            control.resume(pause.continue_file(seq))
+
+            return True
+
+        if answer in ("c", "continue", "all"):
+            control.resume(pause.CONTINUE_ALL)
+
+            print("  continuing, remaining breakpoints skipped")
+
+            return False
+
+        if answer in ("a", "abort"):
+            control.resume(pause.ABORT)
+
+            print("  aborting the test")
+
+            return False
+
+        if answer in ("q", "quit", "exit"):
+            print(f"  leaving the test paused, resume it with:\n"
+                  f"    touch {control.file(pause.continue_file(seq))}")
+
+            return False
+
+        print(KEYS)
+
+
+def main():
+    """
+    Waits for breakpoints and drives them until the test is resumed for good.
+    """
+    parser = argparse.ArgumentParser(description="Drives the ducktests demo 
breakpoints.")
+    parser.add_argument("-d", "--control-dir", 
default=pause.default_control_dir(),
+                        help="control directory shared with the test, defaults 
to "
+                             f"<repository root>/{pause.CONTROL_DIR_NAME}")
+
+    args = parser.parse_args()
+    control = pause.ControlDir(args.control_dir)
+
+    swept = clear_stale(control)
+
+    print(f"Demo console, watching {control.path}")
+
+    if swept:
+        print("Waiting for the first breakpoint... (Ctrl-C to leave)")
+    else:
+        print("A breakpoint is already held, joining it as it is (Ctrl-C to 
leave)")
+
+    last_key, resumed_at = None, None
+
+    while True:
+        status = control.read_status()
+
+        if status is None:
+            # The test removes its status files as it resumes, so this is also 
what tells the
+            # console that the breakpoint it has just driven is over and the 
next one - which
+            # may well repeat its number, in the next test of the session - is 
a new one.
+            last_key = None
+
+            time.sleep(POLL_SEC)
+
+            continue
+
+        if breakpoint_key(status) == last_key:
+            time.sleep(POLL_SEC)
+
+            continue
+
+        last_key = breakpoint_key(status)
+
+        print()
+
+        if resumed_at is not None:
+            print(f"  ({time.monotonic() - resumed_at:.0f}s since the previous 
breakpoint)")
+
+        print("\n".join(status.get("banner", [])))
+        print(KEYS)
+
+        if not prompt(control, status.get("seq")):
+            return
+
+        resumed_at = time.monotonic()
+
+        print("  resumed, waiting for the next breakpoint...")
+
+
+if __name__ == "__main__":
+    try:
+        main()
+    except KeyboardInterrupt:
+        sys.exit(130)
diff --git a/modules/ducktests/tests/docker/requirements-dev.txt 
b/modules/ducktests/tests/docker/requirements-dev.txt
index f868e43d674..8ce0758663a 100644
--- a/modules/ducktests/tests/docker/requirements-dev.txt
+++ b/modules/ducktests/tests/docker/requirements-dev.txt
@@ -16,3 +16,4 @@
 -r requirements.txt
 pytest==6.2.5
 flake8==6.1.0
+tox
diff --git a/modules/ducktests/tests/docker/run_tests.sh 
b/modules/ducktests/tests/docker/run_tests.sh
index 511c106701a..b23ca856dcd 100755
--- a/modules/ducktests/tests/docker/run_tests.sh
+++ b/modules/ducktests/tests/docker/run_tests.sh
@@ -84,6 +84,10 @@ The options are as follows:
 --image
     Set custom docker image to run tests on.
 
+--test-runner-timeout
+    Milliseconds ducktape waits for a sign of life from a running test before 
killing the
+    session, 1800000 by default.
+
 EOF
     exit 0
 }
@@ -131,6 +135,7 @@ while [[ $# -ge 1 ]]; do
         --subnet) SUBNET="--subnet $2"; shift 2;;
         --jdk) JDK_VERSION="$2"; shift 2;;
         --image) IMAGE_NAME="$2"; shift 2;;
+        --test-runner-timeout) TEST_RUNNER_TIMEOUT="$2"; shift 2;;
         -f|--force) FORCE=$1; shift;;
         *) break;;
     esac
@@ -169,5 +174,9 @@ if [[ -n "$REPEAT" ]]; then
   DUCKTAPE_OPTIONS="$DUCKTAPE_OPTIONS --repeat $REPEAT"
 fi
 
+if [[ -n "$TEST_RUNNER_TIMEOUT" ]]; then
+  DUCKTAPE_OPTIONS="$DUCKTAPE_OPTIONS --test-runner-timeout 
$TEST_RUNNER_TIMEOUT"
+fi
+
 "$SCRIPT_DIR"/ducker-ignite test $TC_PATHS "$DUCKTAPE_OPTIONS" \
   || die "ducker-ignite test failed"
diff --git a/modules/ducktests/tests/ignitetest/services/mdc/mdc_cluster.py 
b/modules/ducktests/tests/ignitetest/services/mdc/mdc_cluster.py
index d37b9c045fa..a9ccbe4496d 100644
--- a/modules/ducktests/tests/ignitetest/services/mdc/mdc_cluster.py
+++ b/modules/ducktests/tests/ignitetest/services/mdc/mdc_cluster.py
@@ -195,6 +195,36 @@ class MdcCluster:
 
         return registry
 
+    def describe(self) -> List[str]:
+        """
+        Describes the cluster per data center for a demo breakpoint banner
+        (see :meth:`ignitetest.utils.ignite_test.IgniteTest.pause`). The 
generic banner sees
+        a flat list of services, which is where the DC each node belongs to 
gets lost.
+
+        Structure only - which node is up is what the banner's own service 
section reports,
+        and it pays an SSH probe per node to find out.
+
+        :return: Section lines, the first one being the section title.
+        """
+        lines = ["DATA CENTERS"]
+
+        for dc in DCS:
+            roles = [(label, [node.account.hostname for svc in services for 
node in svc.nodes])
+                     for label, services in (("server", [self.servers[dc]] if 
dc in self.servers else []),
+                                             ("runner", self.runners.get(dc, 
[])),
+                                             ("loader", self.loaders.get(dc, 
[])),
+                                             ("extra", self.extras.get(dc, 
[])))]
+
+            # A DC that holds nothing is not named at all: an empty header 
reads as a DC whose
+            # nodes have gone, which is exactly what a partition demo is being 
watched for.
+            if not any(hosts for _, hosts in roles):
+                continue
+
+            lines.append(f"  {dc}")
+            lines.extend(f"    {label:<7} {' '.join(hosts)}" for label, hosts 
in roles if hosts)
+
+        return lines
+
     def thin_client_addresses(self) -> List[str]:
         """
         :return: Thin client addresses of all server nodes across all DCs.
diff --git 
a/modules/ducktests/tests/ignitetest/services/network_group/manager.py 
b/modules/ducktests/tests/ignitetest/services/network_group/manager.py
index 3f90b7f9450..819212456bd 100644
--- a/modules/ducktests/tests/ignitetest/services/network_group/manager.py
+++ b/modules/ducktests/tests/ignitetest/services/network_group/manager.py
@@ -297,6 +297,29 @@ class NetworkGroupManager:
         """
         self.logger.debug(f"Network State Overview: [START][{log_tag}]")
 
+        # The per-node SSH probes flood the debug log with their own command 
output. Collect
+        # first, print contiguously after: the overview must stay readable as 
one block.
+        for node_status in self._probe_network():
+            self.logger.debug(node_status)
+
+        self.logger.debug(f"Network State Overview: [END][{log_tag}]")
+
+    def describe(self):
+        """
+        Describes the live network state for a demo breakpoint banner
+        (see :meth:`ignitetest.utils.ignite_test.IgniteTest.pause`).
+
+        :return: Section lines, the first one being the section title.
+        """
+        return ["NETWORK"] + [f"  {status}" for status in 
self._probe_network()]
+
+    def _probe_network(self):
+        """
+        Probes the actual network state of every node - the applied netem 
constraints, what
+        they are filtered onto, and the partition drops - rather than what was 
asked for.
+
+        :return: One line per node, grouped by network group.
+        """
         entries = [(group, svc, node)
                    for group, services in self.network_group_registry.items()
                    for svc in services
@@ -314,7 +337,7 @@ class NetworkGroupManager:
             constraints = self._parse_qdisc_constraints(qdisc_lines)
 
             targets_str = f" -> to [{', '.join(dst_ips)}]" if dst_ips and 
constraints != "noqueue" else ""
-            node_ip = socket.gethostbyname(node.account.externally_routable_ip)
+            node_ip = node.account.externally_routable_ip
 
             partition_str = self._format_partition_drops(
                 self._parse_partition_drops(iptables_lines))
@@ -322,12 +345,7 @@ class NetworkGroupManager:
             node_statuses.append(f"[{group:<4}] 
{svc.who_am_i(node):<45}[{node_ip}] : "
                                  f"{constraints}{targets_str}{partition_str}")
 
-        # The per-node SSH probes above flood the debug log with their own 
command output.
-        # Collect first, print contiguously after: the overview must stay 
readable as one block.
-        for node_status in node_statuses:
-            self.logger.debug(node_status)
-
-        self.logger.debug(f"Network State Overview: [END][{log_tag}]")
+        return node_statuses
 
     def _to_network_probe_cmd(self, node) -> str:
         """
diff --git 
a/modules/ducktests/tests/ignitetest/tests/mdc/partition_resilience_test.py 
b/modules/ducktests/tests/ignitetest/tests/mdc/partition_resilience_test.py
index 2ac39298055..e67d807caea 100644
--- a/modules/ducktests/tests/ignitetest/tests/mdc/partition_resilience_test.py
+++ b/modules/ducktests/tests/ignitetest/tests/mdc/partition_resilience_test.py
@@ -63,17 +63,23 @@ class MdcPartitionResilienceTest(IgniteTest):
         with cross_dc_network(self.logger, mdc, delay_ms=cross_dc_latency_ms) 
as net:
             mdc.start_servers()
 
+            self.pause("cluster-up", mdc, net)
+
             mdc.generate_data(DC_1, CACHE_NAME, 0, 100, backups=BACKUPS)
             mdc.generate_data(DC_2, CACHE_NAME, 100, 200, backups=BACKUPS)
 
             mdc.verify_cache_distribution(CACHE_NAME, copies_per_dc=1)
 
+            self.pause("data-loaded", mdc, net)
+
             net.enable_network_partition(DC_1, DC_2)
 
             sleep(SPLIT_SETTLE_SECS)
 
             mdc.verify_split_brain()
 
+            self.pause("split-brain", mdc, net)
+
             # All data written before the split is readable in both halves.
             mdc.check_data(DC_1, CACHE_NAME, 0, 200)
             mdc.check_data(DC_2, CACHE_NAME, 0, 200)
@@ -82,6 +88,8 @@ class MdcPartitionResilienceTest(IgniteTest):
             mdc.check_put_admissibility(DC_1, CACHE_NAME, True)
             mdc.check_put_admissibility(DC_2, CACHE_NAME, False)
 
+            self.pause("secondary-read-only", mdc, net)
+
             net.disable_network_partition(DC_1, DC_2)
 
             # Split-brain does not self-heal: the read-only half rejoins via 
restart.
@@ -94,6 +102,8 @@ class MdcPartitionResilienceTest(IgniteTest):
 
             mdc.control(DC_1).idle_verify(CACHE_NAME)
 
+            self.pause("healed", mdc, net)
+
             mdc.verify_servers_log_clean()
 
             mdc.stop_servers()
diff --git a/modules/ducktests/tests/ignitetest/utils/ignite_test.py 
b/modules/ducktests/tests/ignitetest/utils/ignite_test.py
index 1feda7859ea..d2a71c50e83 100644
--- a/modules/ducktests/tests/ignitetest/utils/ignite_test.py
+++ b/modules/ducktests/tests/ignitetest/utils/ignite_test.py
@@ -23,6 +23,7 @@ from ducktape.cluster.remoteaccount import RemoteCommandError
 from ducktape.tests.test import Test, TestContext
 
 from ignitetest.services.utils.ducktests_service import DucktestsService
+from ignitetest.utils.pause import DemoPause
 
 # globals:
 JFR_ENABLED = "jfr_enabled"
@@ -66,6 +67,50 @@ class IgniteTest(Test):
 
         super().__init__(test_context=test_context)
 
+        self.__demo_pause = None
+
+        # Stamped here rather than at the first breakpoint: it is what demo 
breakpoints count
+        # their elapsed time from, and what they measure the runner budget 
against, and both
+        # of those mean the start of the test - setup included. This is 
deliberately
+        # conservative: ducktape's kill timer is actually reset by the last 
client event
+        # before a pause, which fires after setup, so the real window is 
longer - but
+        # anchoring at construction needs no hook into the runner client and 
never
+        # overestimates the budget.
+        self.__started_at = monotonic()
+
+    def pause(self, name, *describers):
+        """
+        Holds the scenario at a named demo breakpoint until it is resumed from 
the host, so
+        that the cluster can be shown in exactly this state. Does nothing at 
all unless the
+        `demo_pause` global names this breakpoint - see 
:mod:`ignitetest.utils.pause`.
+
+        Must be called from the test body: :meth:`tearDown` kills every 
service, so a
+        breakpoint placed after the body would only ever show a dead cluster.
+
+        :param name: Breakpoint name, matched against the `demo_pause` global.
+        :param describers: Objects exposing `describe() -> list of str`, each 
contributing a
+               section to the banner shown while paused, on top of the service 
list every
+               breakpoint reports. Any fixture a test drives can implement it.
+        """
+        if self.__demo_pause is None:
+            self.__demo_pause = DemoPause(self.logger, 
self.test_context.globals, self.test_context.test_name,
+                                          started_at=self.__started_at,
+                                          
runner_timeout_sec=self.__runner_timeout_sec())
+
+        self.__demo_pause.pause(name, describers, self.test_context.services)
+
+    def __runner_timeout_sec(self):
+        """
+        :return: Ducktape's `--test-runner-timeout` in seconds, None when the 
session carries
+                 none. A breakpoint has to give up inside it, since the runner 
kills a test
+                 client it hears nothing from for that long - see
+                 :meth:`ignitetest.utils.pause.DemoPause._budgeted_timeout`.
+        """
+        session_context = getattr(self.test_context, "session_context", None)
+        timeout_ms = getattr(session_context, "test_runner_timeout", None)
+
+        return timeout_ms / 1000 if timeout_ms else None
+
     @property
     def available_cluster_size(self):
         # noinspection PyUnresolvedReferences
diff --git a/modules/ducktests/tests/ignitetest/utils/pause.py 
b/modules/ducktests/tests/ignitetest/utils/pause.py
new file mode 100644
index 00000000000..82e1096097e
--- /dev/null
+++ b/modules/ducktests/tests/ignitetest/utils/pause.py
@@ -0,0 +1,541 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Demo breakpoints: freezing a scenario at a named point so a live audience can 
be shown the
+cluster in that exact state.
+
+Ducktape runs the test inside the ``ducker01`` container with stdin on 
/dev/null, so a test
+cannot read a keypress. What it can do is share files with the host: 
``ducker-ignite`` bind
+mounts the whole Ignite repository into every container, so a control 
directory below the
+repository root is visible to the test and to the host at the same time.
+
+The directory itself, and the file protocol over it, are
+:class:`ignitetest.utils.pause_control.ControlDir`. This module holds only 
what those files
+*mean*: which breakpoint stops the scenario, what the banner says, and that 
``abort`` ends
+the test.
+
+Between breakpoints both sides sweep the directory for files an earlier run 
left behind,
+which would otherwise skip the next breakpoint: the test at its first one (see
+:meth:`DemoPause._prepare`), the console at startup - and only while nothing 
is published, so
+a resume file meant for a breakpoint that is currently held is never swept.
+
+``docker/demo_console.py`` is the host side of it, but nothing depends on it: 
reading
+``paused.txt`` and touching ``continue-<seq>`` by hand works just as well, 
where ``<seq>`` is
+the breakpoint number shown in the banner's ``PAUSED <seq>`` line.
+
+Globals:
+
+    demo_pause - absent or false disables every breakpoint (the default, so 
tests are
+    unaffected in CI); true or "*" stops at all of them; a list or a comma 
separated string
+    stops only at the named ones, matched case insensitively.
+
+    demo_pause_timeout_sec - how long a single breakpoint may hold the 
scenario before it
+    resumes on its own, 600 by default. Capped by what is left of ducktape's
+    ``--test-runner-timeout``, see :meth:`DemoPause._budgeted_timeout`.
+
+    demo_pause_dir - control directory, ``<repository root>/.ducktests-demo`` 
by default.
+"""
+
+import os
+import time
+from concurrent.futures import ThreadPoolExecutor
+
+from ignitetest.services.utils.path import IgnitePathAware
+from ignitetest.utils.pause_control import ABORT, CONTINUE_ALL, ControlDir, 
continue_file, default_control_dir, \
+    repo_root
+
+# globals:
+DEMO_PAUSE = "demo_pause"
+DEMO_PAUSE_TIMEOUT_SEC = "demo_pause_timeout_sec"
+DEMO_PAUSE_DIR = "demo_pause_dir"
+
+# Well below ducktape's own --test-runner-timeout (1800s), which a breakpoint 
must not
+# outsit - see _budgeted_timeout().
+DEFAULT_TIMEOUT_SEC = 600
+
+# Kept free of the runner budget, so that resuming a breakpoint at the very 
last moment
+# still leaves the scenario time to reach its next event.
+RUNNER_TIMEOUT_MARGIN_SEC = 60
+
+# Same limit as the network group manager: a breakpoint renders its banner 
while the cluster
+# is held, so the SSH probes that report liveness are parallelised to keep 
that render fast.
+_MAX_PARALLEL_SSH = 16
+
+# Timestamps the demo in the test log, so that a run can be read back 
afterwards and it is
+# visible that the scenario is held rather than stuck. Deliberately NOT a 
keepalive towards
+# ducktape: the test logger writes to files and to stdout only, while the 
runner listens for
+# zmq events that just the runner client itself emits - _budgeted_timeout() is 
what keeps a
+# paused test within the runner's patience.
+HEARTBEAT_SEC = 15
+
+# Every breakpoint name matches.
+ALL = "*"
+
+_WIDTH = 100
+
+
+def parse_selector(value):
+    """
+    Interprets the ``demo_pause`` global.
+
+    Names are matched case insensitively, both here and in 
:meth:`DemoPause._stops_at`: the
+    global is typed by hand next to a test whose breakpoint names are written 
in the source.
+
+    :return: None when demo pausing is disabled, :data:`ALL` to stop at every 
breakpoint,
+             or the set of breakpoint names to stop at, lower cased.
+    """
+    if value is None or value is False:
+        return None
+
+    if value is True:
+        return ALL
+
+    if isinstance(value, (list, tuple, set, frozenset)):
+        names = {str(name).strip().lower() for name in value}
+        names.discard("")
+
+        return names or None
+
+    if isinstance(value, str):
+        text = value.strip().lower()
+
+        if text in ("", "false", "no", "off", "0"):
+            return None
+
+        if text in (ALL, "all", "true", "yes", "on", "1"):
+            return ALL
+
+        names = {name.strip() for name in text.split(",")}
+        names.discard("")
+
+        return names or None
+
+    return ALL if value else None
+
+
+def _fmt_duration(seconds):
+    """
+    :return: Duration as mm:ss, or hh:mm:ss once it no longer fits.
+    """
+    seconds = max(int(seconds), 0)
+
+    if seconds >= 3600:
+        return f"{seconds // 3600}:{seconds // 60 % 60:02d}:{seconds % 60:02d}"
+
+    return f"{seconds // 60:02d}:{seconds % 60:02d}"
+
+
+def _resume_command(control, seq):
+    """
+    :return: The command that resumes a breakpoint by hand, named where it 
will be typed.
+
+    Relative to the repository root rather than absolute: the banner is 
rendered inside
+    ``ducker01``, where the repository is mounted at ``/opt/ignite-dev``, and 
read on the host,
+    where that path does not exist - so an absolute one would be a command 
that cannot work
+    for the person it is offered to. Both sides see the same repository 
through the bind
+    mount, which makes the relative form the one string that holds for both, 
and it is the
+    form README documents.
+    """
+    path = control.file(continue_file(seq))
+
+    try:
+        relative = os.path.relpath(path, repo_root())
+    except ValueError:
+        # A control directory on another Windows drive than the repository: 
nothing shared to
+        # be relative to, so the full path is all there is to offer.
+        relative = path
+
+    if relative.startswith(os.pardir) or relative == path:
+        return f"touch {path}"
+
+    return f"touch {relative.replace(os.sep, '/')}   (from the repository 
root)"
+
+
+def _node_host(node):
+    """
+    :return: Hostname of the node, None when it carries no account to read one 
from.
+    """
+    return getattr(getattr(node, "account", None), "hostname", None)
+
+
+def _node_addr(node):
+    """
+    :return: The node's routable address when it adds anything to the name the 
banner already
+             carries - under ducker the two are the same string.
+
+    Deliberately not resolved to an IP: a name that does not resolve costs a 
DNS round trip
+    per node, and a breakpoint that takes seconds to print its banner defeats 
the point.
+    """
+    addr = node.account.externally_routable_ip
+
+    return "" if addr == node.account.hostname else f"[{addr}]"
+
+
+def _node_state(service, node):
+    """
+    :return: Liveness of the node as far as its service can tell, "?" when the 
probe itself
+             failed - a breakpoint must never fail the scenario it is only 
observing.
+    """
+    alive = getattr(service, "alive", None)
+
+    if alive is None:
+        return ""
+
+    try:
+        return "UP" if alive(node) else "DOWN"
+    except Exception:  # pylint: disable=broad-except
+        return "?"
+
+
+def _node_line(service, node, state):
+    """
+    :return: The node's line of the SERVICES section, degraded to the name 
that can be read
+             without asking the service when the service itself cannot answer 
for the node -
+             ``who_am_i`` goes through ``idx()``, which raises for a node the 
service no
+             longer owns.
+
+    The liveness state is probed in parallel by :func:`_probe_liveness` and 
passed in, so
+    that rendering the banner does not wait on one SSH round-trip per node in 
sequence.
+
+    Like :func:`_node_state`, this lets no reading failure out: a breakpoint 
must never fail
+    the scenario it is only observing, least of all while rendering the banner 
it was added
+    for.
+    """
+    try:
+        return f"  {service.who_am_i(node):<58} {_node_addr(node):<17} 
{state}".rstrip()
+    except Exception as ex:  # pylint: disable=broad-except
+        name = f"{type(service).__name__}-{_node_host(node) or '?'}"
+
+        return f"  {name:<58} ({type(ex).__name__})"
+
+
+def _probe_liveness(services):
+    """
+    Probes the liveness of every node across all services concurrently, 
returning a list of
+    state strings in the same order as the ``(service, node)`` pairs the 
caller iterates.
+
+    Each ``alive(node)`` call is an SSH round-trip; on a multi-DC cluster 
there are enough of
+    them that probing in sequence would delay the banner by several seconds. 
The network
+    group manager already parallelises its probes the same way.
+    """
+    pairs = [(service, node) for service in services for node in service.nodes]
+
+    if not pairs:
+        return []
+
+    with ThreadPoolExecutor(max_workers=min(_MAX_PARALLEL_SSH, len(pairs))) as 
pool:
+        return list(pool.map(lambda sn: _node_state(sn[0], sn[1]), pairs))
+
+
+class DemoPause:
+    """
+    Holds a scenario at named breakpoints.
+
+    Disabled unless the ``demo_pause`` global says otherwise, in which case 
:meth:`pause` is
+    a plain return and nothing is written anywhere.
+    """
+    def __init__(self, logger, test_globals, test_name, control_dir=None, 
started_at=None,
+                 runner_timeout_sec=None):
+        """
+        :param started_at: Monotonic timestamp the test itself started at, 
which is both what
+               the banner counts from and what the runner budget is spent 
from. Defaults to
+               now, which is only right when the first breakpoint is the start 
of the test.
+               Anchored at construction (setup included), which is 
deliberately conservative:
+               ducktape's kill timer is actually reset by the last client 
event before the
+               pause, which fires after setup, so the real window is longer.
+        :param runner_timeout_sec: Ducktape's ``--test-runner-timeout`` in 
seconds, None when
+               unknown, in which case no breakpoint is cut short by it.
+        """
+        self.logger = logger
+        self.test_name = test_name
+
+        self.names = parse_selector(test_globals.get(DEMO_PAUSE))
+        self.timeout_sec = float(test_globals.get(DEMO_PAUSE_TIMEOUT_SEC, 
DEFAULT_TIMEOUT_SEC))
+        self.runner_timeout_sec = runner_timeout_sec
+
+        self.control = ControlDir(control_dir or 
test_globals.get(DEMO_PAUSE_DIR) or default_control_dir())
+
+        self.seq = 0
+
+        # Identifies this run of this test to the host console, which has no 
other way of
+        # telling a breakpoint of the current run from one left published by a 
run that
+        # died while paused: seq alone restarts at 1 for every test.
+        self.run = f"{os.getpid()}-{int(time.time())}"
+
+        self._started_at = time.monotonic() if started_at is None else 
started_at
+        self._prepared = False
+        self._continue_all = False
+        self._unusable = False
+
+    @property
+    def enabled(self):
+        """
+        :return: Whether any breakpoint of this test can stop the scenario.
+        """
+        return self.names is not None and not self._continue_all and not 
self._unusable
+
+    def pause(self, name, describers=(), services=()):
+        """
+        Blocks the scenario at the named breakpoint until the host resumes it.
+
+        A control directory that cannot be used costs the demo and nothing 
else - see
+        :meth:`_give_up`.
+
+        :param name: Breakpoint name, matched against the ``demo_pause`` 
global.
+        :param describers: Objects exposing ``describe() -> list of str``, 
each contributing
+               a section to the banner. The first line of a section is its 
title.
+        :param services: Services to list in the banner, normally the test's 
whole registry.
+        """
+        if not self._stops_at(name):
+            return
+
+        try:
+            self._prepare()
+
+            self.seq += 1
+
+            timeout_sec = self._budgeted_timeout()
+
+            banner = self._render(name, describers, services, timeout_sec)
+
+            self._publish(name, banner, timeout_sec)
+        except OSError as ex:
+            self._give_up(name, ex)
+
+            return
+
+        self.logger.info(f"Demo breakpoint reached [seq={self.seq}, 
name={name}, dir={self.control.path}]")
+
+        self._await_resume(name, timeout_sec)
+
+    def _give_up(self, name, error):
+        """
+        Turns the remaining breakpoints off, after the control directory 
turned out to be
+        unusable.
+
+        A breakpoint observes a scenario; it must not be what ends one. The 
directory is a
+        bind mount of the host repository, so it can be read only, be owned by 
another user or
+        be full - none of which says anything about the cluster under test, 
and all of which
+        would otherwise fail a run that was about to pass. Blocking would be 
no better than
+        raising: a breakpoint whose banner never reached the host would hold 
the scenario for
+        its whole timeout with nothing on screen to resume it.
+
+        Every later breakpoint would fail the same way, so they are dropped 
here rather than
+        reported again at each one.
+        """
+        self._unusable = True
+
+        self.logger.warn(f"Demo breakpoints disabled, the control directory 
cannot be used "
+                         f"[dir={self.control.path}, error={error}, 
seq={self.seq}, name={name}, "
+                         f"test={self.test_name}]")
+
+    def _stops_at(self, name):
+        if not self.enabled:
+            return False
+
+        return self.names == ALL or name.strip().lower() in self.names
+
+    def _budgeted_timeout(self):
+        """
+        :return: How long this breakpoint may actually hold the scenario.
+
+        ``demo_pause_timeout_sec`` is what the demo asks for, the runner 
budget is what it is
+        allowed. Ducktape's runner kills a test client it has received no 
event from for
+        ``--test-runner-timeout`` and takes the whole session down with it, 
and a paused test
+        sends no events - so a breakpoint has to give up while the runner is 
still waiting.
+        The budget is spent from the construction of the test (setup included) 
rather than
+        from the breakpoint, hence a long setup, or a long earlier pause, 
leaves less of it
+        for this one. This is deliberately conservative: ducktape resets its 
kill timer on
+        every client event, the last one before a pause being ``"Running..."`` 
(after setup),
+        so the real window is longer by however long setup took - but 
measuring from
+        construction needs no hook into the runner client and never 
overestimates the budget.
+        """
+        if self.runner_timeout_sec is None:
+            return self.timeout_sec
+
+        left = self.runner_timeout_sec - (time.monotonic() - self._started_at) 
- RUNNER_TIMEOUT_MARGIN_SEC
+
+        if left >= self.timeout_sec:
+            return self.timeout_sec
+
+        self.logger.warn(f"Demo breakpoint held for at most 
{_fmt_duration(max(left, 0))} instead of the requested "
+                         f"{_fmt_duration(self.timeout_sec)}: what is left of 
ducktape's --test-runner-timeout "
+                         f"({_fmt_duration(self.runner_timeout_sec)}) after 
{_fmt_duration(self.elapsed_sec)} of "
+                         f"this test. Raise --test-runner-timeout for a longer 
demo "
+                         f"[seq={self.seq}, test={self.test_name}]")
+
+        return max(left, 0.0)
+
+    @property
+    def elapsed_sec(self):
+        """
+        :return: Seconds since the test started.
+        """
+        return time.monotonic() - self._started_at
+
+    def _prepare(self):
+        """
+        Readies the control directory, once per test: a resume file left by a 
previous run
+        would skip the very first breakpoint of this one.
+        """
+        if self._prepared:
+            return
+
+        self.control.prepare()
+
+        self._prepared = True
+
+    def _render(self, name, describers, services, timeout_sec):
+        """
+        :return: The banner as a list of lines.
+        """
+        elapsed = f" t+{_fmt_duration(self.elapsed_sec)} since test start"
+        auto = f"auto-continue in {_fmt_duration(timeout_sec)} "
+
+        lines = [
+            "=" * _WIDTH,
+            f" PAUSED {self.seq}   {name}",
+            f" test  {self.test_name}",
+            (elapsed + auto.rjust(max(_WIDTH - len(elapsed), 1))).rstrip(),
+        ]
+
+        for section in [self._services_section(services)] + [self._section(d) 
for d in describers]:
+            if section:
+                lines.append("-" * _WIDTH)
+                lines.extend(section)
+
+        lines.append("-" * _WIDTH)
+        lines.extend(self._hints_section(services))
+
+        lines.append("-" * _WIDTH)
+        lines.append(" continue: [Enter] in the demo console")
+        lines.append(f"           or  {_resume_command(self.control, 
self.seq)}")
+        lines.append("=" * _WIDTH)
+
+        return lines
+
+    def _section(self, describer):
+        try:
+            return list(describer.describe())
+        except Exception as ex:  # pylint: disable=broad-except
+            self.logger.warn(f"Demo breakpoint describer failed 
[describer={describer}, error={ex}]")
+
+            return []
+
+    @staticmethod
+    def _services_section(services):
+        lines = ["SERVICES"]
+
+        pairs = [(service, node) for service in services for node in 
service.nodes]
+        states = _probe_liveness(services)
+
+        for (service, node), state in zip(pairs, states):
+            lines.append(_node_line(service, node, state))
+
+        if len(lines) == 1:
+            lines.append("  (none)")
+
+        return lines
+
+    @staticmethod
+    def _hints_section(services):
+        """
+        Node logs live only on the nodes while the test runs - ducktape copies 
them into the
+        results directory at teardown - so every hint goes through the node 
container.
+        """
+        # A node the banner could not name is left out rather than allowed to 
fail the hints:
+        # the commands are offered for the nodes that can be entered, and one 
unreadable node
+        # must not cost the demo the rest of them.
+        hosts = sorted({_node_host(node) for service in services for node in 
service.nodes} - {None})
+
+        log_dir = "/mnt/service/logs"
+        config_file = "/mnt/service/config/ignite-config.xml"
+
+        # One set of copy-pasteable commands for a service list that is not 
homogeneous, so
+        # they follow the Ignite services: a ZookeeperService or a 
KafkaService registered
+        # ahead of them - which the discovery and CDC scenarios do - carries 
paths of its own
+        # and would have the banner name a zookeeper.properties for nodes that 
never had one.
+        for service in [s for s in services if isinstance(s, IgnitePathAware)] 
or list(services):
+            try:
+                svc_log_dir = getattr(service, "log_dir", None)
+                svc_config_file = getattr(service, "config_file", None)
+            except Exception:  # pylint: disable=broad-except
+                continue
+
+            log_dir = svc_log_dir or log_dir
+            config_file = svc_config_file or config_file
+
+            break
+
+        # Node paths, joined by hand: they are always POSIX, os.path.join is 
not when the
+        # control machine happens to be Windows.
+        ignite_log = f"{log_dir.rstrip('/')}/ignite*.log"
+        console_log = f"{log_dir.rstrip('/')}/console.log"
+
+        return [
+            f" nodes    {' '.join(hosts) if hosts else '(none)'}",
+            " shell    ./docker/ducker-ignite ssh <node>",
+            f" logs     docker exec <node> bash -c \"tail -n 50 
{ignite_log}\"",
+            f" console  docker exec <node> tail -n 50 {console_log}",
+            f" config   docker exec <node> cat {config_file}",
+        ]
+
+    def _publish(self, name, banner, timeout_sec):
+        """
+        Publishes the breakpoint for the host: the banner to print, plus what 
a reader needs
+        to tell this pause from any other.
+        """
+        self.control.publish(banner, {
+            "run": self.run,
+            "seq": self.seq,
+            "name": name,
+            "test": self.test_name,
+            "elapsed_sec": round(self.elapsed_sec, 1),
+            "timeout_sec": timeout_sec,
+            "banner": banner
+        })
+
+    def _await_resume(self, name, timeout_sec):
+        """
+        Holds the scenario until the host resumes the breakpoint, or until it 
gives up on its
+        own.
+
+        What each resume file means lives here rather than in the control 
directory: it is the
+        only part of the protocol that knows there is a scenario to end.
+        """
+        def still_waiting(left_sec):
+            self.logger.info(f"Still paused at demo breakpoint 
[seq={self.seq}, name={name}, "
+                             f"held={_fmt_duration(timeout_sec - left_sec)}, "
+                             f"left={_fmt_duration(left_sec)}]")
+
+        taken = self.control.await_any([ABORT, CONTINUE_ALL, 
continue_file(self.seq)], timeout_sec,
+                                       tick=still_waiting, 
tick_sec=HEARTBEAT_SEC)
+
+        # Whatever ended the wait, the banner describes a breakpoint that is 
over.
+        self.control.clear_status()
+
+        if taken == ABORT:
+            raise AssertionError(f"Demo aborted at breakpoint [seq={self.seq}, 
name={name}]")
+
+        if taken == CONTINUE_ALL:
+            self._continue_all = True
+
+            self.logger.info(f"Demo resumed, remaining breakpoints skipped 
[seq={self.seq}, name={name}]")
+        elif taken is None:
+            self.logger.warn(f"Demo breakpoint timed out after {timeout_sec}s, 
resuming "
+                             f"[seq={self.seq}, name={name}]")
+        else:
+            self.logger.info(f"Demo resumed [seq={self.seq}, name={name}]")
diff --git a/modules/ducktests/tests/ignitetest/utils/pause_control.py 
b/modules/ducktests/tests/ignitetest/utils/pause_control.py
new file mode 100644
index 00000000000..9a5cf96c0f2
--- /dev/null
+++ b/modules/ducktests/tests/ignitetest/utils/pause_control.py
@@ -0,0 +1,250 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+The directory a paused test and the host talk over, and the file protocol they 
talk in.
+
+The protocol is one sided while a breakpoint is held - the host only ever 
creates files, the
+test is the only party that deletes them - so no step of a held breakpoint can 
race with the
+host:
+
+    - the test publishes ``paused.txt`` (a rendered banner) and 
``paused.json`` (the same
+      content as data) and then blocks;
+    - the host creates ``continue-<seq>``, ``continue-all`` or ``abort``;
+    - the test consumes that file, removes it along with its own status files, 
and proceeds.
+
+Both sides drive it through :class:`ControlDir`, which owns the mechanics 
alone - paths,
+atomic writes, sweeping, polling. What a file *means* is the caller's business:
+:mod:`ignitetest.utils.pause` decides that ``abort`` fails the scenario, and
+``docker/demo_console.py`` decides which one to write.
+
+Standard library only, and deliberately free of every ignitetest import: the 
console loads
+this module by path, on a host that has neither ducktape nor ignitetest 
installed.
+"""
+
+import json
+import os
+import time
+
+CONTROL_DIR_NAME = ".ducktests-demo"
+
+STATUS_TXT = "paused.txt"
+STATUS_JSON = "paused.json"
+CONTINUE_PREFIX = "continue-"
+CONTINUE_ALL = "continue-all"
+ABORT = "abort"
+
+# The control directory is polled rather than watched: it is a bind mount 
shared with the
+# host, where inotify is not dependable.
+POLL_SEC = .5
+
+
+def repo_root():
+    """
+    :return: Path of the Ignite repository root, derived from this module's 
own location
+             
(``<root>/modules/ducktests/tests/ignitetest/utils/pause_control.py``), so that 
a
+             fork checked out elsewhere resolves its own root.
+    """
+    return os.path.abspath(os.path.join(os.path.dirname(__file__), 
*[os.pardir] * 5))
+
+
+def default_control_dir():
+    """
+    :return: Path of the control directory shared between the test and the 
host.
+    """
+    return os.path.join(repo_root(), CONTROL_DIR_NAME)
+
+
+def continue_file(seq):
+    """
+    :return: Name of the file that resumes the breakpoint with the given 
sequence number.
+    """
+    return f"{CONTINUE_PREFIX}{seq}"
+
+
+class ControlDir:
+    """
+    One directory, shared between a paused test and the host driving it.
+
+    Every method here is mechanics. Nothing in this class knows what a 
breakpoint is, which
+    is what lets the test, the console and the checks all speak the protocol 
through the same
+    code rather than through three copies of it that have to agree.
+    """
+    def __init__(self, path):
+        self._path = str(path)
+
+    @property
+    def path(self):
+        """
+        :return: Path of the directory itself, as both sides name it.
+        """
+        return self._path
+
+    def file(self, name):
+        """
+        :return: Path of a control file, for reporting it to someone who will 
type it.
+        """
+        return os.path.join(self._path, name)
+
+    def exists(self, name):
+        """
+        :return: Whether the control file is there.
+        """
+        return os.path.exists(self.file(name))
+
+    def consume(self, name):
+        """
+        Removes a control file if it is present, returning whether it was 
there.
+
+        The test is the only party that deletes what the host writes, so 
consuming
+        a file is what acknowledges the host's command.
+
+        :return: True if the file existed and was removed, False otherwise.
+        """
+        try:
+            os.remove(self.file(name))
+        except OSError:
+            return False
+
+        return True
+
+    def remove(self, name):
+        """
+        Removes a control file, if it is still there - the other side may have 
just swept it.
+        """
+        try:
+            os.remove(self.file(name))
+        except OSError:
+            pass
+
+    def write(self, name, content):
+        """
+        Writes a control file whole: it lands through a temporary name, so the 
other side
+        never reads a half written one.
+        """
+        path = self.file(name)
+        tmp = path + ".tmp"
+
+        with open(tmp, "w", encoding="utf-8") as file:
+            file.write(content)
+
+        os.replace(tmp, path)
+
+    def prepare(self):
+        """
+        Creates the directory and clears anything an earlier run left in it: a 
stale resume
+        file would skip the very first breakpoint of this one.
+        """
+        os.makedirs(self._path, exist_ok=True)
+
+        self.sweep(status=True)
+
+    def sweep(self, status=False):
+        """
+        Removes what an earlier run left behind.
+
+        :param status: Whether to drop a published banner as well. The test 
does, at its first
+               breakpoint. The console must not: it is just as likely to have 
been started
+               against a test that is already holding one.
+        """
+        if not os.path.isdir(self._path):
+            return
+
+        # A write lands through the temporary ``<name>.tmp``: an interrupted 
one leaves it
+        # behind, and this sweep is the only thing that ever visits the 
directory without
+        # knowing what it expects to find, so it strips the suffix before the 
match. Only
+        # ``continue-`` stays a prefix, for its open ended sequence number; 
every other name
+        # matches exactly, so a file the protocol could never create is left 
alone.
+        for name in os.listdir(self._path):
+            base = name[:-len(".tmp")] if name.endswith(".tmp") else name
+
+            stale = base.startswith(CONTINUE_PREFIX) or base == ABORT
+
+            if status:
+                stale = stale or base == STATUS_TXT or base == STATUS_JSON
+
+            if stale:
+                self.remove(name)
+
+    def publish(self, banner, payload):
+        """
+        Publishes a held breakpoint, banner first as text and then as data, so 
that whoever
+        polls for the data never finds it ahead of the text it describes.
+        """
+        self.write(STATUS_TXT, "\n".join(banner) + "\n")
+        self.write(STATUS_JSON, json.dumps(payload, indent=2))
+
+    def read_status(self):
+        """
+        :return: The published breakpoint, or None when nothing is published. 
A missing or
+                 half written file simply reads as "not paused" and is retried 
by the caller.
+        """
+        try:
+            with open(self.file(STATUS_JSON), encoding="utf-8") as file:
+                return json.load(file)
+        except (OSError, ValueError):
+            return None
+
+    def clear_status(self):
+        """
+        Withdraws the published breakpoint, so a stale banner never outlives 
the pause it
+        describes.
+        """
+        for name in (STATUS_TXT, STATUS_JSON):
+            self.remove(name)
+
+    def resume(self, name):
+        """
+        Writes a resume file. The test consumes and removes it.
+
+        Created outright rather than through :meth:`write`: a resume file is 
empty, so there
+        is no half written state for an atomic replace to hide, and the 
temporary name that
+        replace goes through would be one more transient to leave lying about.
+        """
+        with open(self.file(name), "w", encoding="utf-8"):
+            pass
+
+    def await_any(self, names, timeout_sec, tick=None, tick_sec=POLL_SEC):
+        """
+        Polls until one of the named files appears, and consumes it.
+
+        :param names: Names to watch, in priority order - the first one 
present wins when
+               several land between two polls.
+        :param tick: Called with the seconds left, every ``tick_sec`` that 
passes without a
+               file. This is how a caller reports that it is still waiting 
without this class
+               having to know what it would report to.
+        :param tick_sec: How often to do that, by default as often as the 
directory is looked
+               at - which is as often as there is anything new to say.
+        :return: The name that ended the wait, None when the timeout ran out 
first.
+        """
+        deadline = time.monotonic() + timeout_sec
+        next_tick = time.monotonic() + tick_sec if tick else None
+
+        while True:
+            for name in names:
+                if self.consume(name):
+                    return name
+
+            now = time.monotonic()
+
+            if now >= deadline:
+                return None
+
+            if next_tick is not None and now >= next_tick:
+                next_tick = now + tick_sec
+
+                tick(deadline - now)
+
+            time.sleep(POLL_SEC)
diff --git a/modules/ducktests/tests/setup.py b/modules/ducktests/tests/setup.py
index 06d87990776..9065ccc9c98 100644
--- a/modules/ducktests/tests/setup.py
+++ b/modules/ducktests/tests/setup.py
@@ -28,7 +28,7 @@ setup(name="ignitetest",
       author="Apache Ignite",
       platforms=["any"],
       license="apache2.0",
-      packages=find_packages(exclude=["ignitetest.tests", 
"ignitetest.tests.*"]),
+      packages=find_packages(exclude=["ignitetest.tests", 
"ignitetest.tests.*", "checks", "checks.*"]),
       include_package_data=True,
       install_requires=open('docker/requirements.txt').read(),
       tests_require=["pytest==6.2.5"])
diff --git a/modules/ducktests/tests/tox.ini b/modules/ducktests/tests/tox.ini
index e0fa4a74df8..c40e563eb26 100644
--- a/modules/ducktests/tests/tox.ini
+++ b/modules/ducktests/tests/tox.ini
@@ -21,6 +21,12 @@ usedevelop = True
 envdir = {toxworkdir}/.virtualenvs/ignite-ducktests-{envname}
 deps = -r{toxinidir}/docker/requirements-dev.txt
 install_command = pip install --extra-index-url https://pypi.org/simple {opts} 
{packages}
+# pytest builds its tmp_path root below a per user directory, and 
getpass.getuser() has no
+# password database to fall back on when the name is missing from the 
environment - which is
+# what a Windows control machine runs into, since tox passes neither variable 
by default.
+passenv =
+    USER
+    USERNAME
 commands = pytest {env:PYTESTARGS:} {posargs}
 
 [testenv:codestyle]

Reply via email to