okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnwarm: a late tick gets the margin the cache has left, with a floor for jitter8a09b71

main
24.9 KiB659 linesraw
1"""The prompt-cache warmer: an idle session whose children or background jobs
2still run re-sends its last request with a tiny output budget before the
3provider cache would expire.
4
5Catches: pings that rebuild the request instead of repeating the one the turn
6sent (which would compact or summarize), ping request rows that do not carry
7the turn's prefix identity, pings that commit or publish anything, warming
8that never stops at its budget, warming that continues after the waking work
9finished, a session let go cold while its job runs on a remote host its
10kernel cannot list, warming kept up for a job that was started as a service,
11a parent let go cold while its child sits between turns on a build of its
12own, and a submit that cannot get through while a ping holds the session.
13None of this is observable from the transcript alone; the provider and the
14request rows are the witnesses.
15"""
16
17import json
18import os
19import sqlite3
20from datetime import datetime
21import threading
22import time
23import unittest
24
25from harness import (
26    LOOPBACK_REMOTE,
27    Albedo,
28    alive,
29    Provider,
30    exclusive,
31    operation_id,
32    python,
33    release_fifo,
34    text,
35)
36
37
38def milliseconds(timestamp):
39    return round(
40        datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp() * 1000
41    )
42
43
44# A prefix big enough to matter: 4096 cached tokens against the 1024 floor.
45PARENT_USAGE = {
46    "input_tokens": 6000,
47    "output_tokens": 4,
48    "input_tokens_details": {"cached_tokens": 4096},
49}
50CHILD_USAGE = {
51    "input_tokens": 300,
52    "output_tokens": 4,
53    "input_tokens_details": {"cached_tokens": 100},
54}
55
56TASK = "hold the fort until released"
57JOB_PROMPT = "wait for the job"
58CHILD_JOB_TASK = "start the build and report back"
59
60
61# A local cache-table override matching the fixture host: a short refresh TTL,
62# bought at 1.0x and read at 0.3x, so pings come 2.7s apart and at most
63# floor(1.0 / 0.3) - 1 = 2 of them pay for themselves. A tick later than the
64# 2s jitter floor (the 0.3s margin is under the 5s landing time) is dropped.
65TTL_SECONDS = 3
66# Longer than a ping interval by more than a loaded machine's jitter: a ping
67# that was due would have gone out within it.
68QUIET_SECONDS = 2 * TTL_SECONDS
69CACHE_TTL = {
70    "version": 1,
71    "entries": [
72        {
73            "id": "fixture",
74            "match": {"host": "127.0.0.1"},
75            "policy": "refresh",
76            "clock": "request",
77            "tiers": [{"seconds": TTL_SECONDS, "write": 1.0}],
78            "read": 0.3,
79            "evidence": "measured",
80        }
81    ],
82}
83
84
85# Long enough to outlast a slow daemon restart: pings 10s apart, the latest 5s
86# past that, and floor(1.0 / 0.1) - 1 = 9 of them. A 1s idle or unload sweep
87# comes well before the first one.
88RESTART_TTL = {
89    **CACHE_TTL,
90    "entries": [
91        {
92            **CACHE_TTL["entries"][0],
93            "tiers": [{"seconds": 20, "write": 1.0}],
94            "read": 0.1,
95        }
96    ],
97}
98# Past a restarted session's first ping, had there been one.
99RESTART_QUIET_SECONDS = 12
100
101
102def release(gate):
103    wait_for(lambda: release_fifo(gate))
104
105
106def wait_for(predicate, timeout=30):
107    deadline = time.monotonic() + timeout
108    while time.monotonic() < deadline:
109        value = predicate()
110        if value:
111            return value
112        time.sleep(0.05)
113    raise AssertionError("warming never arrived")
114
115
116class WarmTest(unittest.TestCase):
117    def setUp(self):
118        # The child's turn blocks here until the test releases it, so the
119        # parent sits idle on work that will wake it.
120        self.gate = threading.Event()
121        self.job_code = ""
122        self.provider = Provider(self.reply)
123        providers = {
124            "orchestrator": {
125                "baseUrl": self.provider.url + "/parent/v1",
126                "apiKey": "key",
127                "model": "fixture-model",
128                "protocol": "responses",
129            },
130            "scout": {
131                "baseUrl": self.provider.url + "/child/v1",
132                "apiKey": "key",
133                "model": "scout-model",
134                "protocol": "responses",
135            },
136        }
137
138        def prepare(app):
139            (app.home / "cache-ttl.json").write_text(json.dumps(CACHE_TTL))
140            app.write_extensions(
141                {"enabled": {"warm": True}, "warm": {"minCachedTokens": 1024}}
142            )
143
144        self.app = Albedo(
145            self.provider, protocol="responses", providers=providers, prepare=prepare
146        )
147        self.app.__enter__()
148        self.addCleanup(self.provider.close)
149        self.addCleanup(self.app.__exit__, None, None, None)
150        # A failed test must not leave the scout's request blocked.
151        self.addCleanup(self.gate.set)
152
153    def reply(self, request):
154        sent = json.dumps(request)
155        if TASK in sent:
156            self.gate.wait(timeout=90)
157            return text("scout finished", usage=CHILD_USAGE)
158        if CHILD_JOB_TASK in sent:
159            if "function_call_output" not in sent:
160                return python(self.job_code)
161            return text("build started", usage=CHILD_USAGE)
162        if JOB_PROMPT in sent and "function_call_output" not in sent:
163            return python(self.job_code)
164        return text("orchestrator reply", usage=PARENT_USAGE)
165
166    def api(self, path, body=None, method=None):
167        with self.app.api(path, body, method=method) as response:
168            return json.load(response)
169
170    def requests(self, fragment):
171        return [
172            record["request"]
173            for record in self.provider.requests
174            if fragment in record["path"]
175        ]
176
177    def pings(self, session_requests):
178        return [
179            request for request in session_requests if "max_output_tokens" in request
180        ]
181
182    def rows(self, session):
183        with self.app.api(f"/sessions/{session}/context?view=requests") as response:
184            return json.load(response)["items"]
185
186    def history(self, session):
187        items, after = [], 0
188        while True:
189            with self.app.api(
190                f"/sessions/{session}/history?after={after}&limit=100"
191            ) as response:
192                page = json.load(response)
193            items.extend(page["items"])
194            if page["newer"] is None:
195                return items
196            after = items[-1]["position"]
197
198    def spawn(self, parent, task):
199        """The parent's scout, started on `task`."""
200        child = operation_id()
201        self.app.api(
202            f"/sessions/{child}",
203            {
204                "kind": "child",
205                "parent_id": parent,
206                "address": "scout",
207                "name": "scout",
208                "initial_input_id": operation_id(),
209                "task": task,
210                "model": "scout/scout-model",
211            },
212            method="PUT",
213            headers={"If-None-Match": "*"},
214        ).close()
215        return child
216
217    def swarm(self, parent=None):
218        """A parent whose turn has run while its scout is still working."""
219        parent = parent or self.app.session()
220        child = self.spawn(parent, TASK)
221        wait_for(lambda: self.requests("/child/") or None)
222        self.app.prompt(parent, "run the swarm").close()
223        self.app.idle(parent)
224        return parent, child
225
226    def job_gate(self, service, remote=False):
227        """The fifo a job started by `self.job_code` runs until. A remote job
228        runs on a loopback host: the parent's kernel cannot list it, only hear
229        its remote kernel count it."""
230        gate = self.app.workspace / "job-release"
231        os.mkfifo(gate)
232        program = f"open({str(gate)!r}, 'rb').read(1)"
233        flag = ", service=True" if service else ""
234        start = f"run(sys.executable, '-c', {program!r}{flag})"
235        if remote:
236            # The cell ends once the remote kernel has started the job.
237            self.job_code = (
238                LOOPBACK_REMOTE
239                + f"""import asyncio, sys
240job = rem.{start}
241for _ in range(200):
242    if job.id is not None:
243        break
244    await asyncio.sleep(0.05)
245job.id"""
246            )
247        else:
248            self.job_code = f"import sys\njob = {start}\njob.id"
249        # A failed test must not leave the job blocked.
250        self.addCleanup(release_fifo, gate)
251        return gate
252
253    def job(self, service, remote=False):
254        """A parent whose turn started a job that runs until released, and
255        then went idle waiting on it."""
256        gate = self.job_gate(service, remote)
257        parent = self.app.session()
258        self.app.prompt(parent, JOB_PROMPT).close()
259        self.app.idle(parent)
260        return parent, gate
261
262    def child_job(self):
263        """A parent woken by its scout's report that a build it started is
264        running, idle again with the scout idle between turns on that job."""
265        gate = self.job_gate(service=False)
266        parent = self.app.session()
267        child = self.spawn(parent, CHILD_JOB_TASK)
268        wait_for(lambda: self.requests("/parent/") or None)
269        self.app.idle(child)
270        self.app.idle(parent)
271        return parent, child, gate
272
273    def build_finished(self, gate):
274        """Release the scout's build and wait for its end to wake the scout."""
275        release(gate)
276        wait_for(
277            lambda: any(
278                "job finished" in json.dumps(request)
279                for request in self.requests("/child/")
280            )
281        )
282
283    def turns(self, fragment):
284        return [
285            request
286            for request in self.requests(fragment)
287            if "max_output_tokens" not in request
288        ]
289
290    def assert_no_pings(self, parent, child):
291        time.sleep(QUIET_SECONDS)
292        self.assertEqual(len(self.requests("/parent/")), 1)
293        self.assertEqual([row["kind"] for row in self.rows(parent)], ["turn"])
294
295    # exclusive: writes daemon-wide cache TTL and warmer settings
296    @exclusive
297    def test_the_warmer_is_on_unless_disabled(self):
298        settings = json.loads((self.app.home / "extensions.json").read_text())
299        del settings["enabled"]["warm"]
300        (self.app.home / "extensions.json").write_text(json.dumps(settings))
301        parent, child = self.swarm()
302        wait_for(lambda: self.pings(self.requests("/parent/")) or None)
303        self.gate.set()
304        self.app.idle(child)
305        self.app.idle(parent)
306
307    # exclusive: writes daemon-wide cache TTL and warmer settings
308    @exclusive
309    def test_the_warmer_is_off_when_disabled_globally(self):
310        settings = json.loads((self.app.home / "extensions.json").read_text())
311        settings["enabled"]["warm"] = False
312        (self.app.home / "extensions.json").write_text(json.dumps(settings))
313        self.assert_no_pings(*self.swarm())
314
315    # exclusive: writes daemon-wide cache TTL and warmer settings
316    @exclusive
317    def test_a_session_that_disables_the_warmer_is_not_pinged(self):
318        parent = self.app.session()
319        with self.app.api(f"/sessions/{parent}?view=configuration") as response:
320            json.load(response)
321            revision = response.headers["ETag"]
322        with self.app.api(
323            f"/sessions/{parent}?view=configuration",
324            {"selection": {"extensions": {"warm": False}}},
325            method="PATCH",
326            headers={"If-Match": revision},
327        ) as response:
328            self.assertFalse(
329                json.load(response)["session"]["selection"]["effective"]["extensions"][
330                    "warm"
331                ]
332            )
333        self.assert_no_pings(*self.swarm(parent))
334
335    # exclusive: writes daemon-wide cache TTL and warmer settings
336    @exclusive
337    def test_pings_repeat_the_last_request_and_stop_at_the_budget(self):
338        parent, child = self.swarm()
339        before = self.history(parent)
340
341        def two_pings():
342            found = self.pings(self.requests("/parent/"))
343            return found if len(found) == 2 else None
344
345        pings = wait_for(two_pings)
346        turn = self.requests("/parent/")[0]
347        for ping in pings:
348            # A ping is the turn's request as sent, with only the output
349            # budget lowered — the Responses protocol's minimum.
350            self.assertEqual(ping, {**turn, "max_output_tokens": 16})
351
352        # The request rows carry the same prefix identity and cache marks as
353        # the turn they repeat, and never a transcript row of their own.
354        rows = self.rows(parent)
355        self.assertEqual(
356            [row["kind"] for row in rows], ["turn", "background", "background"]
357        )
358        head = rows[0]
359        for row in rows[1:]:
360            self.assertEqual(row["head_hash"], head["head_hash"])
361            self.assertEqual(row["input_count"], head["input_count"])
362            self.assertEqual(row["replaced_input_count"], head["replaced_input_count"])
363            self.assertEqual(row["projection_hash"], head["projection_hash"])
364            self.assertEqual(row["cache_marks"], head["cache_marks"])
365            self.assertEqual(row["cached_tokens"]["observed"], 4096)
366            self.assertEqual(row["transcript_positions"], [])
367            self.assertEqual(row["outcome"], "completed")
368            self.assertEqual(row["provider_profile"], "orchestrator")
369            self.assertEqual(row["model"], "fixture-model")
370        # A ping paid 4096 cached tokens: the hit the TTL model predicted.
371        # Each ping is due an interval after the send before it, timed from
372        # the moment the warmer sent it, which its request row stamps a little
373        # later: rows apart from the turn's are bounded from the turn's.
374        interval = TTL_SECONDS * 900
375        self.assertGreaterEqual(
376            milliseconds(rows[1]["started_at"]),
377            milliseconds(rows[0]["started_at"]) + interval,
378        )
379        self.assertGreaterEqual(
380            milliseconds(rows[2]["started_at"]),
381            milliseconds(rows[0]["started_at"]) + 2 * interval,
382        )
383
384        # The transcript is untouched by warming.
385        self.assertEqual(self.history(parent), before)
386
387        # The budget is spent: no third ping past another interval.
388        time.sleep(QUIET_SECONDS)
389        self.assertEqual(len(self.pings(self.requests("/parent/"))), 2)
390
391        # Once the scout finishes, nothing new is scheduled either.
392        self.gate.set()
393        self.app.idle(child)
394        self.app.idle(parent)
395        time.sleep(QUIET_SECONDS)
396        self.assertEqual(len(self.pings(self.requests("/parent/"))), 2)
397        # The scout's answer woke the parent with an ordinary turn.
398        turns = self.turns("/parent/")
399        self.assertEqual(len(turns), 2)
400        self.assertIn("scout finished", json.dumps(turns[1]))
401
402    # exclusive: writes daemon-wide cache TTL and warmer settings
403    @exclusive
404    def test_a_ping_restarts_the_cached_counts_fade(self):
405        parent, child = self.swarm()
406
407        def fade():
408            with self.app.api(f"/sessions/{parent}?tail=0") as response:
409                return json.load(response)["usage"]["cache_fade"]
410
411        # The fixture's clock counts the TTL from the send's start; nothing is
412        # left after it.
413        turn = self.rows(parent)[0]
414        expires = milliseconds(turn["started_at"]) + TTL_SECONDS * 1000
415        self.assertEqual(
416            [(milliseconds(step["at"]), step["cached_tokens"]) for step in fade()],
417            [(expires, 0)],
418        )
419
420        # A ping resends the prefix, so the provider's clock starts over from
421        # it: the fade moves to the ping's send, which the session times from
422        # just before the request row does.
423        ping = wait_for(
424            lambda: next(
425                (row for row in self.rows(parent) if row["kind"] == "background"), None
426            )
427        )
428        moved = wait_for(
429            lambda: next(
430                (
431                    milliseconds(step["at"])
432                    for step in fade()
433                    if milliseconds(step["at"]) != expires
434                ),
435                None,
436            )
437        )
438        self.assertLessEqual(
439            moved, milliseconds(ping["started_at"]) + TTL_SECONDS * 1000
440        )
441        self.assertGreater(
442            moved, milliseconds(ping["started_at"]) + TTL_SECONDS * 1000 - 500
443        )
444        self.gate.set()
445        self.app.idle(child)
446        self.app.idle(parent)
447
448    # exclusive: writes daemon-wide cache TTL and warmer settings
449    @exclusive
450    def test_a_submit_during_warming_answers_and_warming_ends_with_the_children(self):
451        parent, child = self.swarm()
452
453        first = wait_for(lambda: self.pings(self.requests("/parent/")) or None)
454        turn = self.requests("/parent/")[0]
455        self.assertEqual(first[0], {**turn, "max_output_tokens": 16})
456
457        # A real submit while warming gets through and is answered normally.
458        self.app.prompt(parent, "real work now").close()
459        self.app.idle(parent)
460        self.assertIn(
461            "orchestrator reply",
462            [
463                part["text"]
464                for entry in self.history(parent)
465                for part in entry["content"]
466                if part["kind"] == "text"
467            ],
468        )
469
470        # The work that kept the parent warm is done; warming ends with it.
471        self.gate.set()
472        self.app.idle(child)
473        self.app.idle(parent)
474        settled = len(self.pings(self.requests("/parent/")))
475        time.sleep(QUIET_SECONDS)
476        self.assertEqual(len(self.pings(self.requests("/parent/"))), settled)
477        # Every ping that did go out repeated a turn's request, budget aside.
478        turns = self.turns("/parent/")
479        for ping in self.pings(self.requests("/parent/")):
480            self.assertIn(
481                ping, [{**repeated, "max_output_tokens": 16} for repeated in turns]
482            )
483
484    def assert_warm_until_the_job_ends(self, remote):
485        parent, gate = self.job(service=False, remote=remote)
486        pings = wait_for(lambda: self.pings(self.requests("/parent/")) or None)
487        turn = self.turns("/parent/")[-1]
488        self.assertEqual(pings[0], {**turn, "max_output_tokens": 16})
489
490        # The job's end wakes the parent with an ordinary turn, and warming
491        # ends with the work that kept it up.
492        release(gate)
493        wait_for(
494            lambda: any(
495                "background job finished" in json.dumps(request)
496                for request in self.requests("/parent/")
497            )
498        )
499        self.app.idle(parent)
500        settled = len(self.pings(self.requests("/parent/")))
501        time.sleep(QUIET_SECONDS)
502        self.assertEqual(len(self.pings(self.requests("/parent/"))), settled)
503
504    def assert_a_service_is_not_warmed_for(self, remote):
505        parent, _ = self.job(service=True, remote=remote)
506        time.sleep(QUIET_SECONDS)
507        self.assertEqual(len(self.requests("/parent/")), 2)
508        self.assertEqual([row["kind"] for row in self.rows(parent)], ["turn", "turn"])
509
510    # exclusive: writes daemon-wide cache TTL and warmer settings
511    @exclusive
512    def test_a_running_job_keeps_the_cache_warm_until_it_finishes(self):
513        self.assert_warm_until_the_job_ends(remote=False)
514
515    # exclusive: writes daemon-wide cache TTL and warmer settings
516    @exclusive
517    def test_a_remote_job_keeps_the_cache_warm_until_it_finishes(self):
518        self.assert_warm_until_the_job_ends(remote=True)
519
520    # exclusive: writes daemon-wide cache TTL and warmer settings
521    @exclusive
522    def test_a_service_job_does_not_keep_the_cache_warm(self):
523        self.assert_a_service_is_not_warmed_for(remote=False)
524
525    # exclusive: writes daemon-wide cache TTL and warmer settings
526    @exclusive
527    def test_a_remote_service_job_does_not_keep_the_cache_warm(self):
528        self.assert_a_service_is_not_warmed_for(remote=True)
529
530    # exclusive: writes daemon-wide cache TTL and warmer settings
531    @exclusive
532    def test_a_child_waiting_on_its_job_keeps_the_parent_warm(self):
533        parent, child, gate = self.child_job()
534        # Neither session is in a turn, yet the scout's build will wake it,
535        # and its report the parent.
536        pings = wait_for(lambda: self.pings(self.requests("/parent/")) or None)
537        turns = self.turns("/parent/")
538        self.assertEqual(pings[0], {**turns[-1], "max_output_tokens": 16})
539
540        # The build's end wakes the scout, and warming ends with the work that
541        # kept it up.
542        self.build_finished(gate)
543        self.app.idle(child)
544        self.app.idle(parent)
545        settled = len(self.pings(self.requests("/parent/")))
546        time.sleep(QUIET_SECONDS)
547        self.assertEqual(len(self.pings(self.requests("/parent/"))), settled)
548
549    def restarted_pings(self, wait):
550        """The parent's pings after a restart while it idles on `wait`: a
551        job, a remote job, a service, a child, or a child idle on a job."""
552        (self.app.home / "cache-ttl.json").write_text(json.dumps(RESTART_TTL))
553        if wait == "child":
554            parent, gate = self.swarm()
555        elif wait == "child job":
556            parent, _, gate = self.child_job()
557        else:
558            parent, gate = self.job(
559                service=wait == "service", remote=wait == "remote job"
560            )
561        turn = self.turns("/parent/")[-1]
562        self.app.restart()
563        before = len(self.provider.requests)
564        return (
565            parent,
566            gate,
567            turn,
568            lambda: self.pings(
569                [
570                    record["request"]
571                    for record in self.provider.requests[before:]
572                    if "/parent/" in record["path"]
573                ]
574            ),
575        )
576
577    def assert_a_restart_keeps_warming_for(self, wait):
578        parent, gate, turn, pings = self.restarted_pings(wait)
579        # The warmer's call lived in memory only; the restarted session
580        # rebuilds it from the transcript, exactly as the turn sent it.
581        ping = wait_for(pings)[0]
582        self.assertEqual(ping, {**turn, "max_output_tokens": 16})
583        # The provider answers before the ping's request row is written.
584        rows = wait_for(
585            lambda: (rows := self.rows(parent))[-1]["kind"] == "background" and rows
586        )
587        head = [row for row in rows if row["kind"] == "turn"][-1]
588        self.assertEqual(rows[-1]["head_hash"], head["head_hash"])
589        self.assertEqual(rows[-1]["input_count"], head["input_count"])
590
591        release(gate)
592        wait_for(
593            lambda: any(
594                "background job finished" in json.dumps(request)
595                for request in self.requests("/parent/")
596            )
597        )
598        self.app.idle(parent)
599
600    # exclusive: writes daemon-wide cache TTL and warmer settings, restarts
601    @exclusive
602    def test_a_restart_keeps_warming_a_session_waiting_on_a_job(self):
603        self.assert_a_restart_keeps_warming_for("job")
604
605    # exclusive: writes daemon-wide cache TTL and warmer settings, restarts
606    @exclusive
607    def test_a_restart_keeps_warming_a_session_waiting_on_a_remote_job(self):
608        # The reattached kernel's hello carries its remote jobs' count.
609        self.assert_a_restart_keeps_warming_for("remote job")
610
611    # exclusive: shortens the daemon's idle and unload limits, restarts
612    @exclusive
613    def test_a_parent_waiting_on_its_child_keeps_warming_once_released(self):
614        (self.app.home / "cache-ttl.json").write_text(json.dumps(RESTART_TTL))
615        self.app.restart(
616            prepare=lambda app: app.env.update(
617                ALBEDO_IDLE_SECONDS="1", ALBEDO_UNLOAD_SECONDS="1"
618            )
619        )
620        parent, child = self.swarm()
621        with sqlite3.connect(self.app.home / "albedo.sqlite") as database:
622            (kernel,) = database.execute(
623                "SELECT pid FROM kernel_links WHERE session=?", (parent,)
624            ).fetchone()
625        # The idle sweep takes the kernel long before the first ping is due,
626        # and the unload sweep would stop the session with its warmer.
627        wait_for(lambda: not alive(kernel))
628        self.assertEqual(self.pings(self.requests("/parent/")), [])
629        wait_for(lambda: self.pings(self.requests("/parent/")), timeout=40)
630        self.gate.set()
631        self.app.idle(child)
632        self.app.idle(parent)
633
634    # exclusive: writes daemon-wide cache TTL and warmer settings, restarts
635    @exclusive
636    def test_a_restart_keeps_warming_a_parent_waiting_on_its_child(self):
637        parent, child, turn, pings = self.restarted_pings("child")
638        # The restart resumes the scout mid-turn, and its parent with it.
639        self.assertEqual(wait_for(pings)[0], {**turn, "max_output_tokens": 16})
640        self.gate.set()
641        self.app.idle(child)
642        self.app.idle(parent)
643
644    # exclusive: writes daemon-wide cache TTL and warmer settings, restarts
645    @exclusive
646    def test_a_restart_keeps_warming_a_parent_whose_child_waits_on_a_job(self):
647        parent, gate, turn, pings = self.restarted_pings("child job")
648        # The scout's kernel comes back running its build, which brings back
649        # the parent waiting on its report.
650        self.assertEqual(wait_for(pings)[0], {**turn, "max_output_tokens": 16})
651        self.build_finished(gate)
652        self.app.idle(parent)
653
654    # exclusive: writes daemon-wide cache TTL and warmer settings, restarts
655    @exclusive
656    def test_a_restart_does_not_warm_a_session_with_only_a_service(self):
657        _, _, _, pings = self.restarted_pings("service")
658        time.sleep(RESTART_QUIET_SECONDS)
659        self.assertEqual(pings(), [])