okami.mom/albedo

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

Aly Raffauffeat(daemon): add native storage helper modes159a58a

main
5.7 KiB141 linesraw
1"""Catch orphaned cleanup workers with deterministic gates inside unlink and VACUUM.
2
3The real CLI and daemon E2E tests cannot pause these operations. These tests
4run the production helper, replacing only the operation's scheduling with an
5acknowledged gate. EOF or death must stop that one process and release its lock.
6"""
7
8from contextlib import closing
9import json
10import os
11from pathlib import Path
12import sqlite3
13import subprocess
14import sys
15import tempfile
16import unittest
17
18sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
19from storage_helper_support import maintenance_command
20
21OWNER = """
22import json, signal, subprocess, sys
23helper = subprocess.Popen(sys.argv[1:],
24    stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
25helper.stdin.write(json.dumps({'paths': []}) + '\\n')
26helper.stdin.flush()
27assert json.loads(helper.stdout.readline()) == {'ready': True}
28print(helper.pid, flush=True)
29signal.pause()
30"""
31
32
33class MaintenanceLifetimeTests(unittest.TestCase):
34    def assert_released(self, home):
35        with sqlite3.connect(home / "daemon.lock", timeout=0) as db:
36            db.execute("BEGIN EXCLUSIVE")
37
38    def assert_owned(self, home):
39        with closing(sqlite3.connect(home / "daemon.lock", timeout=0)) as db:
40            with self.assertRaises(sqlite3.OperationalError) as refused:
41                db.execute("BEGIN EXCLUSIVE")
42            self.assertIn(
43                refused.exception.sqlite_errorcode,
44                (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED),
45            )
46
47    def close_process(self, process):
48        if process.poll() is None:
49            process.kill()
50        process.wait(timeout=10)
51        for pipe in (process.stdin, process.stdout, process.stderr):
52            if pipe and not pipe.closed:
53                pipe.close()
54
55    def test_death_or_eof_during_delete_and_vacuum_releases_ownership(self):
56        for operation in ("delete", "vacuum"):
57            for death in ("helper killed", "owner EOF"):
58                with (
59                    self.subTest(operation=operation, death=death),
60                    tempfile.TemporaryDirectory() as directory,
61                ):
62                    home = Path(directory)
63                    candidate = home / "candidate"
64                    candidate.write_bytes(b"approved")
65                    with sqlite3.connect(home / "albedo.sqlite") as db:
66                        db.execute("CREATE TABLE reclaim(data BLOB)")
67                        db.execute("INSERT INTO reclaim VALUES(zeroblob(262144))")
68                        db.execute("DELETE FROM reclaim")
69                    before = (home / "albedo.sqlite").read_bytes()
70                    worker = subprocess.Popen(
71                        maintenance_command(home, operation=operation),
72                        stdin=subprocess.PIPE,
73                        stdout=subprocess.PIPE,
74                        stderr=subprocess.PIPE,
75                        text=True,
76                    )
77                    self.addCleanup(self.close_process, worker)
78                    assert worker.stdin is not None and worker.stdout is not None
79                    worker.stdin.write(json.dumps({"paths": [str(candidate)]}) + "\n")
80                    worker.stdin.flush()
81                    self.assertEqual(
82                        json.loads(worker.stdout.readline()), {"ready": True}
83                    )
84                    worker.stdin.write(
85                        json.dumps(
86                            {
87                                "apply": True,
88                                "vacuum": operation == "vacuum",
89                                "before": len(before),
90                                "free_pages": 1,
91                            }
92                        )
93                        + "\n"
94                    )
95                    worker.stdin.flush()
96                    self.assertEqual(
97                        json.loads(worker.stdout.readline()), {"working": operation}
98                    )
99                    self.assert_owned(home)
100                    if death == "helper killed":
101                        worker.kill()
102                    else:
103                        worker.stdin.close()
104                    self.assertNotEqual(worker.wait(timeout=10), 0)
105                    self.assert_released(home)
106                    if operation == "delete":
107                        self.assertEqual(candidate.read_bytes(), b"approved")
108                    else:
109                        self.assertFalse(candidate.exists())
110                        self.assertEqual((home / "albedo.sqlite").read_bytes(), before)
111                    self.close_process(worker)
112
113    @unittest.skipUnless(hasattr(os, "pidfd_open"), "requires Linux pidfds")
114    def test_owner_killed_before_authorization_releases_helper(self):
115        import select
116
117        with tempfile.TemporaryDirectory() as directory:
118            home = Path(directory)
119            owner = subprocess.Popen(
120                [sys.executable, "-c", OWNER, *maintenance_command(home)],
121                stdout=subprocess.PIPE,
122                stderr=subprocess.PIPE,
123                text=True,
124            )
125            self.addCleanup(self.close_process, owner)
126            assert owner.stdout is not None
127            helper_pid = int(owner.stdout.readline())
128            descriptor = os.pidfd_open(helper_pid)
129            try:
130                owner.kill()
131                owner.wait(timeout=10)
132                readable, _, _ = select.select([descriptor], [], [], 10)
133                self.assertEqual(readable, [descriptor], "helper survived its owner")
134                self.assert_released(home)
135            finally:
136                os.close(descriptor)
137            self.close_process(owner)
138
139
140if __name__ == "__main__":
141    unittest.main()