diff --git a/README.md b/README.md
index 3c55774b..6d49639b 100644
--- a/README.md
+++ b/README.md
@@ -535,6 +535,7 @@ A rollback leaves the clone on a detached HEAD at the restored commit; the updat
- The log rotates (10 MB × 5) in `data/webvirtcloud.log`.
- A disk can be grown while its VM runs or is paused, not only when it is shut off.
- A host's CPU graph shows the average since its previous point (normally 5 s) instead of a 1 s sample taken while a worker waits.
+- A VM's stats are polled every 5 s like the host graph, the first point as soon as the tab opens; each point is the average since the previous one, disk rates in MB/s (they were MiB/s labelled Mb/s); a counter the host does not report shows a gap instead of 0.
- A running VM without a guest agent shows the IPv4 address the host learned by ARP (the lookup always failed before).
- After a live migration the VM's NVRAM file is deleted on the source, when it is in libvirt's `/var/lib/libvirt/qemu/nvram` and the destination does not share that directory (a VM created there later with the same name would start with those UEFI variables).
- gstfsd finds the guest's operating system the way libguestfs inspects it, so a root on LVM works, and it always answers (an error when it finds no or several operating systems). Update `/usr/local/bin/gstfsd` on every compute (see [Adding a Compute Node](#adding-a-compute-node), step 3).
diff --git a/instances/templates/instance.html b/instances/templates/instance.html
index 0dc5fc73..aebf4da7 100644
--- a/instances/templates/instance.html
+++ b/instances/templates/instance.html
@@ -381,8 +381,13 @@
", self.page(), re.S) if "statsSample" in s)
+ js = """
+ const vm = require("vm");
+ const charts = [], requests = [], handlers = {};
+ let poll, fail = false;
+ const canvas = {get: () => ({getContext: () => ({})})};
+ const $ = () => canvas;
+ $.getJSON = (url, params) => {
+ requests.push(params);
+ return {fail: (callback) => { if (fail) callback(); return {}; }};
+ };
+ const page = {
+ $,
+ document: {querySelector: () => ({addEventListener: (name, handler) => { handlers[name] = handler; }})},
+ // Chart.js adds the lists a config leaves out
+ Chart: function (ctx, config) {
+ this.data = Object.assign({labels: []}, config.data);
+ this.data.datasets.forEach((set) => { set.data = set.data || []; });
+ this.options = config.options;
+ this.update = () => {};
+ charts.push(this);
+ },
+ pollWhileVisible: (interval, request, onData, first) => { poll = {interval, first, request, onData}; return {}; },
+ };
+ vm.runInNewContext(SCRIPT, vm.createContext(page));
+ handlers["shown.bs.tab"]();
+ const answer = (cpu, sample) => ({cpudata: cpu, window: 9.9, sample: sample, timeline: "t",
+ memdata: {used: 0, total: 1024},
+ // vdb was attached after the page loaded
+ blkdata: [{dev: "vdb", data: [9, 9]}, {dev: "vda", data: [1.5, null]}],
+ netdata: [{dev: 0, data: [2, 3]}]});
+ poll.request();
+ poll.onData(answer(3, "token-1"));
+ poll.request();
+ fail = true;
+ poll.request();
+ fail = false;
+ poll.request();
+ const label = charts[0].options.tooltips.callbacks.label({datasetIndex: 0, index: 0, yLabel: 3}, charts[0].data);
+ console.log(JSON.stringify({requests, label, every: [poll.interval, poll.first], cpu: charts[0].data.datasets[0].data,
+ disk: charts[2].data.datasets[1].data, net: charts[3].data.datasets[0].data}));
+ """.replace("SCRIPT", json.dumps(script))
+ out = subprocess.run(["node", "-e", js], capture_output=True, text=True, timeout=30)
+ self.assertEqual(out.returncode, 0, out.stderr)
+ result = json.loads(out.stdout)
+ # the first poll at once, then every 5 s
+ self.assertEqual(result["every"], [5000, 0])
+ # the sample goes with the next poll; a failed poll drops it
+ self.assertEqual(result["requests"], [{}, {"sample": "token-1"}, {"sample": "token-1"}, {}])
+ # a disk without a chart is skipped, the other charts still update
+ self.assertEqual((result["cpu"], result["disk"], result["net"]), ([3], [None], [2]))
+ self.assertTrue(result["label"].endswith(": 3 % (average over 9.9 s)"), result["label"])
diff --git a/instances/views.py b/instances/views.py
index b8ca3c65..f78d9aac 100755
--- a/instances/views.py
+++ b/instances/views.py
@@ -22,6 +22,7 @@ from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.models import User
+from django.core import signing
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import Http404, HttpResponse, JsonResponse
from django.db.models import prefetch_related_objects
@@ -41,7 +42,7 @@ from logs.views import addlogmsg
from webvirtcloud.middleware import error_text
from vrtManager import util
from vrtManager.create import wvmCreate
-from vrtManager.instance import wvmInstances
+from vrtManager.instance import USAGE_MAX_WINDOW, wvmInstances
from vrtManager.interface import wvmInterface
from vrtManager.storage import wvmStorage
from vrtManager.util import randomPasswd
@@ -54,6 +55,8 @@ from .models import Flavor
# the VM page waits this long (seconds) for the compute's drbdadm status
DRBD_STATUS_TIMEOUT = 10
+# the stats tab sends the sample of each answer back with its next poll
+STATS_SAMPLE_SIGNER = signing.TimestampSigner(salt="instances.stats-sample")
def index(request):
@@ -269,41 +272,42 @@ def drbd_status(request, pk):
return result
+def _stats_sample(instance, token):
+ """The usage sample in a token of this VM's stats on its current compute,
+ or None when there is none, it is altered or too old."""
+ if not token:
+ return None
+ try:
+ data = STATS_SAMPLE_SIGNER.unsign_object(token, max_age=USAGE_MAX_WINDOW)
+ except signing.BadSignature:
+ return None
+ if data.get("vm") != [instance.id, instance.compute_id, instance.uuid]:
+ return None
+ return data["sample"]
+
+
def stats(request, pk):
instance = get_instance(request.user, pk)
- json_blk = []
- json_net = []
-
- # TODO: stats are inaccurate
- cpu_usage, blk_usage, net_usage = instance.proxy.usage()
+ usage = instance.proxy.usage(_stats_sample(instance, request.GET.get("sample")))
mem_usage = instance.proxy.mem_usage()
- current_time = time.strftime("%H:%M:%S")
- for blk in blk_usage:
- json_blk.append(
- {
- "dev": blk["dev"],
- "data": [int(blk["rd"]) / 1048576, int(blk["wr"]) / 1048576],
- }
- )
+ def mega(value):
+ return None if value is None else value / 10**6
- for net in net_usage:
- json_net.append(
- {
- "dev": net["dev"],
- "data": [int(net["rx"]) / 1048576, int(net["tx"]) / 1048576],
- }
+ result = {
+ "cpudata": None if usage["cpu"] is None else round(usage["cpu"], 1),
+ "memdata": mem_usage,
+ # MB/s and Mbit/s
+ "blkdata": [{"dev": disk["dev"], "data": [mega(disk["rd"]), mega(disk["wr"])]} for disk in usage["disks"]],
+ "netdata": [{"dev": nic["dev"], "data": [mega(nic["rx"]), mega(nic["tx"])]} for nic in usage["nics"]],
+ "timeline": time.strftime("%H:%M:%S"),
+ }
+ if "sample" in usage:
+ result["window"] = None if usage["window"] is None else round(usage["window"], 1)
+ result["sample"] = STATS_SAMPLE_SIGNER.sign_object(
+ {"vm": [instance.id, instance.compute_id, instance.uuid], "sample": usage["sample"]}, compress=True
)
-
- return JsonResponse(
- {
- "cpudata": int(cpu_usage["cpu"]),
- "memdata": mem_usage,
- "blkdata": json_blk,
- "netdata": json_net,
- "timeline": current_time,
- }
- )
+ return JsonResponse(result)
def osinfo(request, pk):
diff --git a/static/js/poll.js b/static/js/poll.js
index 91b12fb5..113ee05c 100644
--- a/static/js/poll.js
+++ b/static/js/poll.js
@@ -5,8 +5,9 @@
// interval ms after the previous one has finished, so a slow host does not
// pile up requests, each holding a web worker. A hidden page asks nothing;
// when it is shown again it asks at once. stop() pauses, start() resumes
-// at once (for example while a tab of the page is shown).
-function pollWhileVisible(interval, request, onData) {
+// at once (for example while a tab of the page is shown). The first request
+// goes out after firstDelay ms, the interval if it is not given.
+function pollWhileVisible(interval, request, onData, firstDelay) {
var timer = null;
var running = false;
var stopped = false;
@@ -34,7 +35,7 @@ function pollWhileVisible(interval, request, onData) {
}
document.addEventListener("visibilitychange", resume);
- timer = setTimeout(run, interval);
+ timer = setTimeout(run, firstDelay === undefined ? interval : firstDelay);
return {
stop: function () {
diff --git a/vrtManager/instance.py b/vrtManager/instance.py
index a92f1841..67ff8cb0 100644
--- a/vrtManager/instance.py
+++ b/vrtManager/instance.py
@@ -60,6 +60,16 @@ from vrtManager.storage import wvmStorage, wvmStorages
# with secrets. Live XML would drop pending changes, non-secure XML the VNC
# password.
PERSISTENT_XML = VIR_DOMAIN_XML_INACTIVE | VIR_DOMAIN_XML_SECURE
+# the longest window wvmInstance.usage() averages over
+USAGE_MAX_WINDOW = 30
+
+
+def _rate(before, after, seconds, scale=1):
+ """Growth per second of a counter, or None when a reading is unsupported
+ (-1) or the counter went back."""
+ if before < 0 or after < before:
+ return None
+ return scale * (after - before) / seconds
class wvmInstances(wvmConnect):
@@ -1266,68 +1276,91 @@ class wvmInstance(wvmConnect):
mem_usage["total"] = 0
return mem_usage
- def usage(self):
+ def usage(self, previous=None):
"""
CPU (percent of the host), disk read/write bytes and network rx/tx
- bits of the last second, for the stats view. All counters are read
- before and after one shared second: the view runs in a web worker,
- and a second per device kept it busy for 1 + disks + NICs seconds.
+ bits per second, for the stats view. With previous, the "sample" of an
+ earlier answer, they are averages since then and nothing waits; without
+ a usable one, over one second. A sample is usable for the same run of
+ the VM (domain ID) on a host with the same CPUs and devices, at most
+ USAGE_MAX_WINDOW seconds old. A value the host does not report (-1) or
+ whose counter went back is None.
"""
- devices = []
tree = ElementTree.fromstring(self._XMLDesc(0))
+ disks = []
for disk in tree.findall("devices/disk"):
- if disk.get("device") == "disk":
- dev_file = None
- dev_bus = None
- network_disk = True
- for elm in disk:
- if elm.tag == "source":
- if elm.get("protocol"):
- dev_file = elm.get("protocol")
- network_disk = True
- if elm.get("file"):
- dev_file = elm.get("file")
- if elm.get("dev"):
- dev_file = elm.get("dev")
- if elm.tag == "target":
- dev_bus = elm.get("dev")
- if (dev_file and dev_bus) is not None:
- if network_disk:
- dev_file = dev_bus
- devices.append([dev_file, dev_bus])
+ source, target = disk.find("source"), disk.find("target")
+ if (
+ disk.get("device") == "disk"
+ and source is not None
+ and any(source.get(name) for name in ("protocol", "file", "dev", "volume"))
+ and target is not None
+ and target.get("dev")
+ ):
+ disks.append(target.get("dev")) # blockStats takes the target name
+ # all NICs, in the order of the page's charts; one without a target is not measured
nics = []
- running = self.get_status() == 1
- if running:
- tree = ElementTree.fromstring(self._XMLDesc(0))
- for target in tree.findall("devices/interface/target"):
- nics.append(target.get("dev"))
+ for nic in tree.findall("devices/interface"):
+ mac, target = nic.find("mac"), nic.find("target")
+ nics.append([
+ "" if mac is None else mac.get("address", ""),
+ "" if target is None else target.get("dev", ""),
+ ])
- def sample():
- return (
+ if self.get_status() != 1:
+ return {
+ "cpu": 0,
+ "disks": [{"dev": dev, "rd": 0, "wr": 0} for dev in disks],
+ "nics": [{"dev": i, "rx": 0, "tx": 0} for i in range(len(nics))],
+ }
+
+ key = [self.instance.ID(), self._host_info()[2], disks, nics]
+ unread = (-1,) * 8
+
+ def read():
+ blocks = [self.instance.blockStats(dev) for dev in disks]
+ links = [self.instance.interfaceStats(target) if target else unread for _, target in nics]
+ return [
self.instance.info()[4],
- [self.instance.blockStats(dev[0]) for dev in devices],
- [self.instance.interfaceStats(dev) for dev in nics],
- )
+ [[stats[1], stats[3]] for stats in blocks],
+ [[stats[0], stats[4]] for stats in links],
+ time.monotonic(),
+ ]
- cpu_usage = {"cpu": 0}
- dev_usage = [{"dev": dev[1], "rd": 0, "wr": 0} for dev in devices]
- if not running:
- net_usage = [{"dev": i, "rx": 0, "tx": 0} for i, _ in enumerate(self.get_net_devices())]
- return cpu_usage, dev_usage, net_usage
+ def rates(before, after):
+ seconds = after[3] - before[3]
+ if before[0] > after[0] or not 0 < seconds <= USAGE_MAX_WINDOW:
+ return None
+ return {
+ "cpu": 100 * (after[0] - before[0]) / (seconds * key[1] * 10**9),
+ "disks": [
+ {"dev": dev, "rd": _rate(old[0], new[0], seconds), "wr": _rate(old[1], new[1], seconds)}
+ for dev, old, new in zip(disks, before[1], after[1])
+ ],
+ "nics": [
+ {"dev": i, "rx": _rate(old[0], new[0], seconds, 8), "tx": _rate(old[1], new[1], seconds, 8)}
+ for i, (old, new) in enumerate(zip(before[2], after[2]))
+ ],
+ "window": seconds,
+ }
- nbcore = self.wvm.getInfo()[2]
- cpu_ago, blk_ago, net_ago = sample()
- time.sleep(1)
- cpu_now, blk_now, net_now = sample()
- cpu_usage["cpu"] = 100 * (cpu_now - cpu_ago) / (1 * nbcore * 10**9)
- for usage, ago, now in zip(dev_usage, blk_ago, blk_now):
- usage["rd"] = now[1] - ago[1]
- usage["wr"] = now[3] - ago[3]
- net_usage = [
- {"dev": i, "rx": (now[0] - ago[0]) * 8, "tx": (now[4] - ago[4]) * 8}
- for i, (ago, now) in enumerate(zip(net_ago, net_now))
- ]
- return cpu_usage, dev_usage, net_usage
+ counters = read()
+ usage = None
+ if previous is not None and previous.get("key") == key:
+ usage = rates(previous["counters"], counters)
+ if usage is None:
+ before = counters
+ time.sleep(1)
+ counters = read()
+ # None everywhere if the VM restarted meanwhile
+ usage = rates(before, counters) or {
+ "cpu": None,
+ "disks": [{"dev": dev, "rd": None, "wr": None} for dev in disks],
+ "nics": [{"dev": i, "rx": None, "tx": None} for i in range(len(nics))],
+ "window": None,
+ }
+ usage["sample"] = {"key": key, "counters": counters}
+ return usage
def get_telnet_port(self):
telnet_port = None
diff --git a/vrtManager/test_usage.py b/vrtManager/test_usage.py
index 70782407..ca25b62a 100644
--- a/vrtManager/test_usage.py
+++ b/vrtManager/test_usage.py
@@ -1,5 +1,7 @@
-"""VM statistics sample CPU, disks and NICs over one shared second: the stats
-view runs in a sync worker, polled every 10 s by each open VM page."""
+"""VM statistics: rates are averages since the sample of the previous answer,
+without waiting, when it is from the same run of the VM on the same host and
+devices; otherwise over one second. The stats view runs in a sync worker,
+polled every 5 s by each open VM page."""
import unittest
from unittest.mock import patch
@@ -9,69 +11,168 @@ from django.conf import settings
if not settings.configured:
settings.configure(MAC_OUI="52:54:10")
-from vrtManager.instance import wvmInstance
+from vrtManager.instance import USAGE_MAX_WINDOW, wvmInstance
XML = """
-
+
-
+
+"""
class FakeDomain:
- """Counters grow by a fixed amount per elapsed second (per sleep)."""
+ """Counters grow by a fixed amount per second of the clock."""
- def __init__(self, state):
+ def __init__(self, state, clock):
self.state = state
- self.second = 0
+ self.clock = clock
+ self.domain_id = 7
+ self.unsupported = False
+
+ def now(self):
+ return self.clock[0]
+
+ def ID(self):
+ return self.domain_id
def info(self):
- return [self.state, 0, 0, 2, self.second * 500_000_000] # 0.5 s of CPU time per second
+ return [self.state, 0, 0, 2, int(self.now() * 500_000_000)] # 0.5 s of CPU time per second
def blockStats(self, dev):
n = {"vda": 1, "vdb": 2}[dev] # disks are read by target name
- return (0, self.second * n * 1000, 0, self.second * n * 3000, 0)
+ written = -1 if self.unsupported and dev == "vdb" else int(self.now() * n * 3000)
+ return (0, int(self.now() * n * 1000), 0, written, 0)
def interfaceStats(self, dev):
- return (self.second * 100, 0, 0, 0, self.second * 50, 0, 0, 0)
+ assert dev == "vnet0"
+ return (int(self.now() * 100), 0, 0, 0, int(self.now() * 50), 0, 0, 0)
-class FakeConn:
- def getInfo(self):
- return ["x86_64", 0, 4] # 4 host CPUs
-
-
-def instance(state):
+def instance(state, clock):
proxy = wvmInstance.__new__(wvmInstance) # no connection: only these calls
- proxy.instance = FakeDomain(state)
- proxy.wvm = FakeConn()
+ proxy._read_cache = None
+ proxy.instance = FakeDomain(state, clock)
proxy._XMLDesc = lambda flags: XML
- proxy.get_net_devices = lambda: [{"mac": "52:54:00:00:00:01"}]
+ proxy._host_info = lambda: ["x86_64", 0, 4] # 4 host CPUs
+ proxy.get_status = lambda: state
return proxy
class UsageTestCase(unittest.TestCase):
- def sample(self, state):
- proxy = instance(state)
+ def setUp(self):
+ self.clock = [100.0]
+ patcher = patch("vrtManager.instance.time")
+ self.addCleanup(patcher.stop)
+ self.time = patcher.start()
+ self.time.monotonic.side_effect = lambda: self.clock[0]
+ self.time.sleep.side_effect = self.advance
- def sleep(seconds):
- proxy.instance.second += seconds
+ def advance(self, seconds):
+ self.clock[0] += seconds
- with patch("vrtManager.instance.time.sleep", side_effect=sleep) as slept:
- result = proxy.usage()
- return result, slept
+ def usage(self, proxy, previous=None):
+ result = proxy.usage(previous)
+ self.time.sleep.reset_mock()
+ return result
- def test_a_running_vm_is_sampled_over_one_second(self):
- (cpu, disks, nets), slept = self.sample(1)
- slept.assert_called_once_with(1)
- self.assertEqual(cpu, {"cpu": 12.5}) # 0.5 s of 4 host CPUs
- self.assertEqual(disks, [{"dev": "vda", "rd": 1000, "wr": 3000}, {"dev": "vdb", "rd": 2000, "wr": 6000}])
- self.assertEqual(nets, [{"dev": 0, "rx": 800, "tx": 400}]) # bits
+ def test_without_a_sample_over_one_second(self):
+ proxy = instance(1, self.clock)
+ result = proxy.usage()
+ self.time.sleep.assert_called_once_with(1)
+ self.assertEqual((result["cpu"], result["window"]), (12.5, 1.0)) # 0.5 s of 4 host CPUs
+ # a pool volume is measured like a file
+ self.assertEqual(
+ result["disks"], [{"dev": "vda", "rd": 1000, "wr": 3000}, {"dev": "vdb", "rd": 2000, "wr": 6000}]
+ )
+ # bits; the NIC without a target is not measured and keeps its place
+ self.assertEqual(result["nics"], [{"dev": 0, "rx": None, "tx": None}, {"dev": 1, "rx": 800, "tx": 400}])
+ self.assertEqual(result["sample"]["counters"][3], 101.0)
- def test_a_vm_that_is_not_running_is_not_sampled(self):
- (cpu, disks, nets), slept = self.sample(5)
- slept.assert_not_called()
- self.assertEqual(cpu, {"cpu": 0})
- self.assertEqual(disks, [{"dev": "vda", "rd": 0, "wr": 0}, {"dev": "vdb", "rd": 0, "wr": 0}])
- self.assertEqual(nets, [{"dev": 0, "rx": 0, "tx": 0}])
+ def test_since_the_previous_sample_without_waiting(self):
+ proxy = instance(1, self.clock)
+ sample = self.usage(proxy)["sample"]
+ self.advance(9.5)
+ result = proxy.usage(sample)
+ self.time.sleep.assert_not_called()
+ self.assertEqual((result["cpu"], result["window"]), (12.5, 9.5))
+ self.assertEqual(result["disks"][1], {"dev": "vdb", "rd": 2000, "wr": 6000}) # per second
+ self.assertEqual(result["nics"][1], {"dev": 1, "rx": 800, "tx": 400})
+ self.assertEqual(result["sample"]["counters"][3], 110.5)
+
+ def test_a_sample_of_another_run_host_or_devices_is_not_used(self):
+ def restarted(proxy):
+ proxy.instance.domain_id = 8
+
+ def other_host_cpus(proxy):
+ proxy._host_info = lambda: ["x86_64", 0, 8]
+
+ def nic_renamed(proxy):
+ proxy._XMLDesc = lambda flags: XML.replace("vnet0", "vnet1")
+
+ for change in (restarted, other_host_cpus, nic_renamed):
+ with self.subTest(change.__name__):
+ proxy = instance(1, self.clock)
+ sample = self.usage(proxy)["sample"]
+ change(proxy)
+ self.advance(5)
+ proxy.instance.interfaceStats = lambda dev: (0,) * 8
+ self.assertEqual(proxy.usage(sample)["window"], 1.0)
+ self.time.sleep.assert_called_once_with(1)
+ self.time.sleep.reset_mock()
+
+ def test_a_sample_too_old_from_the_future_or_counting_back_is_not_used(self):
+ for name, seconds in (("too old", USAGE_MAX_WINDOW + 1), ("future", -5), ("same time", 0)):
+ with self.subTest(name):
+ proxy = instance(1, self.clock)
+ sample = self.usage(proxy)["sample"]
+ self.advance(seconds)
+ self.assertEqual(proxy.usage(sample)["window"], 1.0)
+ self.time.sleep.assert_called_once_with(1)
+ self.time.sleep.reset_mock()
+ proxy = instance(1, self.clock)
+ sample = self.usage(proxy)["sample"]
+ sample["counters"][0] += 10**12 # CPU time went back: a restart with the same domain ID
+ self.advance(5)
+ self.assertEqual(proxy.usage(sample)["cpu"], 12.5)
+ self.time.sleep.assert_called_once_with(1)
+
+ def test_a_counter_the_host_does_not_report_has_no_value(self):
+ proxy = instance(1, self.clock)
+ proxy.instance.unsupported = True
+ disks = proxy.usage()["disks"]
+ self.assertEqual(disks[1], {"dev": "vdb", "rd": 2000, "wr": None})
+
+ def test_a_vm_restarted_during_the_second_has_no_values(self):
+ proxy = instance(1, self.clock)
+ domain = proxy.instance
+ real_sleep = self.time.sleep.side_effect
+
+ def restart(seconds):
+ real_sleep(seconds)
+ domain.info = lambda: [1, 0, 0, 2, 0] # CPU time starts over
+
+ self.time.sleep.side_effect = restart
+ result = proxy.usage()
+ self.assertEqual((result["cpu"], result["window"]), (None, None))
+ self.assertEqual(result["disks"][0], {"dev": "vda", "rd": None, "wr": None})
+ self.assertIn("sample", result)
+
+ def test_a_paused_or_shut_off_vm_is_not_sampled(self):
+ for state in (3, 5):
+ with self.subTest(state=state):
+ result = instance(state, self.clock).usage()
+ self.time.sleep.assert_not_called()
+ self.assertEqual(
+ result,
+ {
+ "cpu": 0,
+ "disks": [{"dev": "vda", "rd": 0, "wr": 0}, {"dev": "vdb", "rd": 0, "wr": 0}],
+ "nics": [{"dev": 0, "rx": 0, "tx": 0}, {"dev": 1, "rx": 0, "tx": 0}],
+ },
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/webvirtcloud/js_tests/poll.test.js b/webvirtcloud/js_tests/poll.test.js
index 54a90f7b..e49fae0b 100644
--- a/webvirtcloud/js_tests/poll.test.js
+++ b/webvirtcloud/js_tests/poll.test.js
@@ -92,4 +92,13 @@ assert.strictEqual(answers.length, 8, "each answer reaches onData");
failNext = true; requests[8].finish();
assert.ok(requests[8].error, "the error reaches the page");
advance(10000); assert.strictEqual(requests.length, 10, "polling goes on after an error in onData");
+
+// a first delay of 0: the first request at once, then every interval
+poll.stop(); requests[9].finish();
+const atOnce = pollWhileVisible(5000, request, onData, 0);
+advance(0); assert.strictEqual(requests.length, 11, "first request after firstDelay");
+requests[10].finish();
+advance(4999); assert.strictEqual(requests.length, 11);
+advance(1); assert.strictEqual(requests.length, 12, "then every interval");
+atOnce.stop();
console.log("poll.js: all checks passed");