okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
f6da61d
main
1"""Offline cleanup and daemon startup must exclude each other, even without HTTP.""" 2 3from contextlib import closing 4import hashlib 5import json 6import os 7import pickle 8import signal 9import select 10import sqlite3 11import socket 12import subprocess 13import sys 14import tempfile 15from pathlib import Path 16import time 17import unittest 18 19from harness import Albedo ,ROOT ,exclusive ,wait_until 20 21from storage_helper_support import maintenance_command 22 23 24class StorageTests (unittest .TestCase ): 25def helper (self ,app ,paths = ()): 26helper = subprocess .Popen ( 27maintenance_command (app .home ,app .env ["ALBEDO_DAEMON" ]), 28env = app .env , 29stdin = subprocess .PIPE , 30stdout = subprocess .PIPE , 31stderr = subprocess .PIPE , 32text = True , 33 ) 34self .addCleanup (self .close_helper ,helper ) 35assert helper .stdin is not None and helper .stdout is not None 36helper .stdin .write (json .dumps ({"paths" : [str (path )for path in paths ]})+ "\n" ) 37helper .stdin .flush () 38self .assertEqual (json .loads (helper .stdout .readline ()), {"ready" :True }) 39return helper 40 41def close_helper (self ,helper ): 42if helper .stdin and not helper .stdin .closed : 43helper .stdin .close () 44try : 45helper .wait (timeout = 10 ) 46finally : 47if helper .poll ()is None : 48helper .kill () 49helper .wait () 50if helper .stdout : 51helper .stdout .close () 52if helper .stderr : 53helper .stderr .close () 54 55def backup (self ,app ): 56path = app .home / "backups/albedo-before-image-store-1.sqlite" 57path .parent .mkdir (exist_ok = True ) 58path .write_bytes (b"approved backup" ) 59old = time .time ()- 31 * 24 * 60 * 60 60os .utime (path , (old ,old )) 61return path 62 63# exclusive: restarts the daemon around offline maintenance 64@exclusive 65def test_maintenance_refuses_startup_and_second_cleanup_then_releases (self ): 66with Albedo (providers = {})as app : 67backup = self .backup (app ) 68 69def maintenance (app ): 70database = app .home / "albedo.sqlite" 71before = hashlib .sha256 (database .read_bytes ()).digest () 72helper = self .helper (app , [backup ]) 73refused = subprocess .run ( 74 [app .env ["ALBEDO_DAEMON" ]], 75env = dict (app .env ,ALBEDO_TOKEN = "x" * 32 ), 76cwd = ROOT , 77capture_output = True , 78timeout = 20 , 79 ) 80self .assertNotEqual (refused .returncode ,0 ) 81self .assertIn (b"storage is in use" ,refused .stdout + refused .stderr ) 82self .assertEqual (hashlib .sha256 (database .read_bytes ()).digest (),before ) 83cleanup = subprocess .run ( 84 [ 85str (ROOT / "cli/bin/albedo" ), 86"storage" , 87"prune" , 88"--all" , 89"--yes" , 90 ], 91env = app .env , 92cwd = ROOT , 93capture_output = True , 94timeout = 20 , 95 ) 96self .assertNotEqual (cleanup .returncode ,0 ) 97self .assertIn (b"storage is in use" ,cleanup .stderr ) 98self .assertEqual (backup .read_bytes (),b"approved backup" ) 99self .close_helper (helper ) 100self .assertEqual (helper .returncode ,0 ) 101 102app .restart (prepare = maintenance ) 103self .assertEqual (backup .read_bytes (),b"approved backup" ) 104self .assertEqual (app .api ("/server" ).status ,200 ) 105 106# exclusive: suspends the daemon and inspects global storage 107@exclusive 108@ unittest . skipUnless ( os . path . isdir ( "/proc/self" ), "requires Linux process status" ) 109def test_unreachable_daemon_keeps_cleanup_out (self ): 110with Albedo (providers = {})as app : 111backup = self .backup (app ) 112pid = app .connection ["pid" ] 113with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 114db .execute ("CREATE TABLE reclaim(data BLOB)" ) 115db .execute ("INSERT INTO reclaim VALUES(zeroblob(262144))" ) 116db .execute ("DELETE FROM reclaim" ) 117before = app .cli ("storage" ,"--offline" ,"--json" ) 118os .kill (pid ,signal .SIGSTOP ) 119try : 120deadline = time .monotonic ()+ 10 121while True : 122with open (f"/proc/ { pid } /status" )as status : 123if "State:\tT" in status .read (): 124break 125self .assertLess ( 126time .monotonic (),deadline ,"daemon did not suspend" 127 ) 128unavailable = subprocess .run ( 129 [str (ROOT / "cli/bin/albedo" ),"storage" ,"--json" ], 130env = dict (app .env ,ALBEDO_DAEMON = "/nonexistent/no-launch" ), 131cwd = ROOT , 132capture_output = True , 133timeout = 30 , 134 ) 135self .assertNotEqual (unavailable .returncode ,0 ) 136self .assertEqual (unavailable .stdout ,b"" ) 137cleanup = subprocess .run ( 138 [ 139str (ROOT / "cli/bin/albedo" ), 140"storage" , 141"prune" , 142"--all" , 143"--yes" , 144 ], 145env = app .env , 146cwd = ROOT , 147capture_output = True , 148timeout = 20 , 149 ) 150self .assertNotEqual (cleanup .returncode ,0 ) 151self .assertEqual (backup .read_bytes (),b"approved backup" ) 152self .assertEqual ( 153json .loads (app .cli ("storage" ,"--offline" ,"--json" )), 154json .loads (before ), 155 ) 156finally : 157os .kill (pid ,signal .SIGCONT ) 158 159# exclusive: restarts the daemon around offline maintenance 160@exclusive 161@ unittest . skipUnless (hasattr( os , "pidfd_open" ), "requires Linux pidfds" ) 162def test_cli_death_and_signals_stop_its_mutation_helper (self ): 163with Albedo (providers = {})as app : 164backup = self .backup (app ) 165 166def maintenance (app ): 167gate = app .home / "work-gate" 168os .mkfifo (gate ) 169descriptor = os .open (gate ,os .O_RDWR | os .O_NONBLOCK ) 170self .addCleanup (os .close ,descriptor ) 171tools = app .home / "test-tools" 172tools .mkdir () 173wrapper = tools / "albedo-daemon" 174held = maintenance_command ( 175app .home ,app .env ["ALBEDO_DAEMON" ],operation = "delete" ,gate = gate 176 ) 177wrapper .write_text ( 178f"#! { sys . executable } \n" 179"import os, sys\n" 180"if sys.argv[1:3] == ['storage', 'maintain']:\n" 181f" command = { held !r } \n" 182"else:\n" 183f" command = [ { app . env [ 'ALBEDO_DAEMON' ]!r } , *sys.argv[1:]]\n" 184"os.execv(command[0], command)\n" 185 ) 186wrapper .chmod (0o700 ) 187for death in (signal .SIGKILL ,signal .SIGINT ,signal .SIGTERM ): 188with self .subTest (signal = death ): 189command = subprocess .Popen ( 190 [ 191str (ROOT / "cli/bin/albedo" ), 192"storage" , 193"prune" , 194"--backups" , 195"--yes" , 196 ], 197env = dict (app .env ,ALBEDO_DAEMON = str (wrapper )), 198cwd = ROOT , 199stdout = subprocess .PIPE , 200stderr = subprocess .PIPE , 201 ) 202self .addCleanup (self .close_helper ,command ) 203readable ,_ ,_ = select .select ([descriptor ], [], [],20 ) 204self .assertEqual ( 205readable , [descriptor ],"CLI never reached deletion" 206 ) 207helper_pid = int (os .read (descriptor ,1024 )) 208helper_descriptor = os .pidfd_open (helper_pid ) 209try : 210command .send_signal (death ) 211self .assertNotEqual (command .wait (timeout = 10 ),0 ) 212readable ,_ ,_ = select .select ( 213 [helper_descriptor ], [], [],10 214 ) 215self .assertEqual ( 216readable , 217 [helper_descriptor ], 218"mutation helper survived CLI" , 219 ) 220with sqlite3 .connect ( 221app .home / "daemon.lock" ,timeout = 0 222 )as db : 223db .execute ("BEGIN EXCLUSIVE" ) 224self .assertEqual (backup .read_bytes (),b"approved backup" ) 225finally : 226os .close (helper_descriptor ) 227self .close_helper (command ) 228 229app .restart (prepare = maintenance ) 230 231# exclusive: restarts the daemon and races startup against offline cleanup 232@exclusive 233def test_daemon_started_after_preview_refuses_cleanup_before_mutation (self ): 234with Albedo (providers = {})as app : 235backup = self .backup (app ) 236 237def maintenance (app ): 238ready = app .home / "acquisition-ready" 239release = app .home / "acquisition-release" 240os .mkfifo (ready ) 241os .mkfifo (release ) 242descriptor = os .open (ready ,os .O_RDWR | os .O_NONBLOCK ) 243self .addCleanup (os .close ,descriptor ) 244tools = app .home / "test-tools" 245tools .mkdir () 246wrapper = tools / "albedo-daemon" 247wrapper .write_text ( 248f"#! { sys . executable } \n" 249"import os, sys\n" 250"if sys.argv[1:3] == ['storage', 'maintain']:\n" 251f" with open( { str ( ready )!r } , 'w') as pipe:\n" 252" pipe.write('acquiring')\n" 253f" with open( { str ( release )!r } ) as pipe:\n" 254" pipe.read(1)\n" 255f"os.execv( { app . env [ 'ALBEDO_DAEMON' ]!r } , [ { app . env [ 'ALBEDO_DAEMON' ]!r } , *sys.argv[1:]])\n" 256 ) 257wrapper .chmod (0o700 ) 258cleanup = subprocess .Popen ( 259 [ 260str (ROOT / "cli/bin/albedo" ), 261"storage" , 262"prune" , 263"--all" , 264"--yes" , 265 ], 266env = dict (app .env ,ALBEDO_DAEMON = str (wrapper )), 267cwd = ROOT , 268stdout = subprocess .PIPE , 269stderr = subprocess .PIPE , 270 ) 271self .addCleanup (self .close_helper ,cleanup ) 272readable ,_ ,_ = select .select ([descriptor ], [], [],20 ) 273self .assertEqual ( 274readable , [descriptor ],"cleanup never approved its preview" 275 ) 276self .assertEqual (os .read (descriptor ,1024 ),b"acquiring" ) 277# The CLI has previewed and approved storage, but has not claimed 278# ownership. Boot the runner's real daemon before releasing it. 279app .daemon .boot () 280with release .open ("w" )as pipe : 281pipe .write ("x" ) 282_ ,stderr = cleanup .communicate (timeout = 20 ) 283self .assertNotEqual (cleanup .returncode ,0 ) 284self .assertIn (b"storage is in use" ,stderr ) 285self .assertEqual (backup .read_bytes (),b"approved backup" ) 286self .close_helper (cleanup ) 287 288app .restart (prepare = maintenance ) 289 290# The private home catches filename encoding regressions and Python invocation. 291def test_offline_inspection_and_prune_do_not_invoke_python (self ): 292with Albedo (providers = {})as app ,tempfile .TemporaryDirectory ()as directory : 293root = Path (directory ) 294home = root / "home ?%é" 295home .mkdir () 296database = home / "albedo.sqlite" 297with closing (sqlite3 .connect (database ))as db : 298db .executescript ( 299"CREATE TABLE sessions(id TEXT); CREATE TABLE transcript(session TEXT,payload BLOB); CREATE TABLE reclaim(data BLOB);" 300 ) 301db .execute ("INSERT INTO reclaim VALUES(zeroblob(262144))" ) 302db .execute ("DELETE FROM reclaim" ) 303db .commit () 304backup = home / "backups/albedo-before-image-store-1.sqlite" 305backup .parent .mkdir () 306backup .write_bytes (b"approved backup" ) 307old = time .time ()- 31 * 24 * 60 * 60 308os .utime (backup , (old ,old )) 309tools = root / "tools" 310tools .mkdir () 311python = tools / "python3" 312python .write_text ("#!/bin/sh\nexit 99\n" ) 313python .chmod (0o700 ) 314env = dict ( 315app .env , 316ALBEDO_HOME = str (home ), 317PATH = str (tools )+ os .pathsep + app .env ["PATH" ], 318 ) 319for arguments in (("--offline" ,"--json" ), ("prune" ,"--all" ,"--yes" )): 320result = subprocess .run ( 321 [str (ROOT / "cli/bin/albedo" ),"storage" ,* arguments ], 322env = env , 323cwd = ROOT , 324capture_output = True , 325text = True , 326timeout = 20 , 327 ) 328self .assertEqual (result .returncode ,0 ,result .stderr ) 329if arguments [0 ]== "--offline" : 330self .assertGreater (json .loads (result .stdout )["db" ]["free_pages" ],0 ) 331self .assertFalse (backup .exists ()) 332self .assertFalse ((home / "daemon.json" ).exists ()) 333with closing (sqlite3 .connect (database ))as db : 334self .assertEqual (db .execute ("PRAGMA freelist_count" ).fetchone ()[0 ],0 ) 335 336# exclusive: restarts the daemon and mutates global storage offline 337@exclusive 338def test_successful_cleanup_retains_session_files_and_releases_ownership (self ): 339with Albedo ()as app : 340session = app .session () 341history = app .history (session ) 342 343def maintenance (app ): 344kernels = app .home / "kernels" 345kernels .mkdir (exist_ok = True ) 346backups = app .home / "backups" 347backups .mkdir (exist_ok = True ) 348live = kernels / f" { session } .state" 349old_orphan = kernels / "old-orphan.state" 350recent_orphan = kernels / "recent-orphan.state" 351old_backup = backups / "albedo-before-image-store-1.sqlite" 352recent_backup = backups / "albedo-before-image-store-2.sqlite" 353snapshot = pickle .dumps ( 354 {"cwd" :str (app .workspace ),"names" : {},"definitions" : []} 355 ) 356retained = { 357live :snapshot , 358recent_orphan :b"recent state" , 359recent_backup :b"recent backup" , 360 } 361for path ,content in retained .items (): 362path .write_bytes (content ) 363old_orphan .write_bytes (snapshot ) 364old_backup .write_bytes (b"old backup" ) 365old = time .time ()- 31 * 24 * 60 * 60 366for path in (live ,old_orphan ,old_backup ): 367os .utime (path , (old ,old )) 368 369with closing (sqlite3 .connect (app .home / "albedo.sqlite" ))as db : 370db .execute ("CREATE TABLE cleanup_reclaim(data BLOB)" ) 371db .execute ("INSERT INTO cleanup_reclaim VALUES(zeroblob(1048576))" ) 372db .execute ("DELETE FROM cleanup_reclaim" ) 373db .commit () 374before = json .loads (app .cli ("storage" ,"--json" )) 375self .assertGreater (before ["db" ]["free_pages" ],0 ) 376 377app .cli ("storage" ,"prune" ,"--all" ,"--yes" ) 378 379for path in (old_orphan ,old_backup ): 380self .assertFalse (path .exists (),f"cleanup retained { path } " ) 381for path ,content in retained .items (): 382self .assertEqual (path .read_bytes (),content ) 383after = json .loads (app .cli ("storage" ,"--json" )) 384self .assertEqual (after ["db" ]["free_pages" ],0 ) 385self .assertLess (after ["database" ],before ["database" ]) 386self .assertEqual (after ["db" ]["sessions" ],before ["db" ]["sessions" ]) 387with closing ( 388sqlite3 .connect (app .home / "daemon.lock" ,timeout = 0 ) 389 )as db : 390db .execute ("BEGIN EXCLUSIVE" ) 391 392app .restart (prepare = maintenance ) 393with app .api ("/server" )as response : 394self .assertEqual (response .status ,200 ) 395self .assertEqual (app .history (session ),history ) 396 397# exclusive: mutates global storage and asserts complete daemon-wide reports 398@exclusive 399def test_online_report_uses_daemon_storage_without_client_disk_or_python (self ): 400with Albedo ()as app : 401session = app .session () 402# The daemon reclaims unknown state files a day old on its first 403# sweep after a start, then only daily. Let a restart's first 404# sweep run, seen through a sentinel it removes, so the orphan the 405# report must list is not reclaimed underneath it. Restart before 406# the rows below are written: they are not a transcript a daemon 407# would boot on. 408kernels = app .home / "kernels" 409kernels .mkdir (exist_ok = True ) 410old = time .time ()- 31 * 24 * 60 * 60 411sentinel = kernels / "sweep-sentinel.state" 412sentinel .write_bytes (b"sentinel" ) 413os .utime (sentinel , (old ,old )) 414app .restart () 415wait_until ( 416lambda :not sentinel .exists (),30 ,"the first expiry sweep never ran" 417 ) 418image_reference = b'{"image_hash":"storage-report-image"}' 419source = "é🐈" 420trace = b"trace" 421expected_session_bytes = ( 4223 + len (image_reference )* 2 + len (source )+ len (trace ) 423 ) 424with closing (sqlite3 .connect (app .home / "albedo.sqlite" ))as db : 425db .execute ("DELETE FROM transcript WHERE session=?" , (session ,)) 426db .execute ( 427"INSERT INTO transcript(session,payload) VALUES(?,?)" , 428 (session ,image_reference ), 429 ) 430if "pinned_context" in { 431row [1 ]for row in db .execute ("PRAGMA table_info(sessions)" ) 432 }: 433db .execute ( 434"UPDATE sessions SET pinned_context=? WHERE id=?" , 435 ("pin" ,session ), 436 ) 437db .execute ( 438"INSERT INTO images(hash,data) VALUES(?,?)" , 439 ("storage-report-image" ,b"imagebytes" ), 440 ) 441db .execute ( 442"INSERT INTO cells(id,session,source,status,payload) VALUES(?,?,?,?,?)" , 443 ( 444"storage-report-cell" , 445session , 446source , 447"finished" , 448image_reference , 449 ), 450 ) 451db .execute ( 452"INSERT INTO cell_traces(id,payload) VALUES(?,?)" , 453 ("storage-report-cell" ,trace ), 454 ) 455db .execute ("CREATE TABLE report_reclaim(data BLOB)" ) 456db .execute ("INSERT INTO report_reclaim VALUES(zeroblob(262144))" ) 457db .execute ("DELETE FROM report_reclaim" ) 458db .commit () 459orphan = kernels / "report-orphan.state" 460live = kernels / f" { session } .state" 461orphan .write_bytes (b"orphan" ) 462live .write_bytes (b"live" ) 463backups = app .home / "backups" 464baseline_backups = ( 465sum (path .stat ().st_size for path in backups .iterdir ()if path .is_file ()) 466if backups .exists () 467else 0 468 ) 469backup = self .backup (app ) 470recent_backup = backup .parent / "albedo-before-image-store-2.sqlite" 471recent_backup .write_bytes (b"recent" ) 472unrelated_backup = backup .parent / "personal.sqlite" 473unrelated_backup .write_bytes (b"personal" ) 474for path in (orphan ,live ): 475os .utime (path , (old ,old )) 476offline = json .loads (app .cli ("storage" ,"--offline" ,"--json" )) 477expected_other = sum ( 478path .stat ().st_size 479for path in app .home .iterdir () 480if path .is_file () 481and path .name 482not in {"albedo.sqlite" ,"albedo.sqlite-wal" ,"albedo.sqlite-shm" } 483 ) 484with tempfile .TemporaryDirectory ()as directory : 485client_home = Path (directory ) 486 (client_home / "daemon.json" ).write_bytes ( 487 (app .home / "daemon.json" ).read_bytes () 488 ) 489# These paths cannot be inspected as storage, and any attempted 490# diagnostic Python launch leaves an observable marker. 491 (client_home / "albedo.sqlite" ).mkdir () 492 (client_home / "kernels" ).write_bytes (b"not a directory" ) 493tools = client_home / "tools" 494tools .mkdir () 495marker = client_home / "python-called" 496python = tools / "python3" 497python .write_text (f"#!/bin/sh\ntouch ' { marker } '\nexit 91\n" ) 498python .chmod (0o700 ) 499command = subprocess .run ( 500 [str (ROOT / "cli/bin/albedo" ),"storage" ,"--json" ], 501env = dict ( 502app .env , 503ALBEDO_HOME = str (client_home ), 504PATH = str (tools )+ os .pathsep + app .env ["PATH" ], 505 ), 506cwd = ROOT , 507capture_output = True , 508text = True , 509timeout = 20 , 510 ) 511self .assertEqual (command .returncode ,0 ,command .stderr ) 512report = json .loads (command .stdout ) 513self .assertEqual ( 514report ["db" ]["sessions" ], 515 [{"id" :session ,"bytes" :expected_session_bytes }], 516 ) 517self .assertEqual (report ["db" ]["images" ],10 ) 518self .assertEqual ( 519report ["old_kernels" ], [{"path" :str (orphan ),"bytes" :6 }] 520 ) 521self .assertEqual ( 522report ["old_backups" ], [{"path" :str (backup ),"bytes" :15 }] 523 ) 524self .assertEqual (report ["kernels" ],10 ) 525self .assertEqual (report ["backups" ],baseline_backups + 29 ) 526self .assertEqual (report ["recent_backups" ],6 ) 527self .assertEqual (report ["recent_backup_count" ],1 ) 528self .assertEqual (report ["other" ],expected_other ) 529self .assertGreater (report ["db" ]["free_pages" ],0 ) 530for key in ( 531"old_kernels" , 532"old_backups" , 533"kernels" , 534"backups" , 535"other" , 536"recent_backups" , 537"recent_backup_count" , 538 ): 539self .assertEqual (report [key ],offline [key ],key ) 540self .assertEqual (report ["db" ],offline ["db" ]) 541self .assertGreater (report ["database" ],0 ) 542self .assertGreater (report ["db" ]["page_size" ],0 ) 543self .assertFalse ( 544marker .exists (),"online reporting invoked client Python" 545 ) 546record = json .loads ((client_home / "daemon.json" ).read_text ()) 547self .assertEqual (record ,app .connection ) 548 549# Only the private client home changes; the daemon is not stopped or mutated. 550def test_offline_reports_supported_older_layouts_and_rejects_unknown_without_changes ( 551self , 552 ): 553with Albedo (providers = {})as app ,tempfile .TemporaryDirectory ()as directory : 554home = Path (directory )/ "missing" 555 556def report (* options ,path = None ): 557return subprocess .run ( 558 [str (ROOT / "cli/bin/albedo" ),"storage" ,* options ,"--json" ], 559env = dict ( 560app .env , 561ALBEDO_HOME = str (home ), 562ALBEDO_DAEMON = app .env ["ALBEDO_DAEMON" ], 563PATH = app .env ["PATH" ]if path is None else path , 564 ), 565cwd = ROOT , 566capture_output = True , 567text = True , 568timeout = 20 , 569 ) 570 571missing = report ("--offline" ,path = "" ) 572self .assertEqual (missing .returncode ,0 ,missing .stderr ) 573empty = json .loads (missing .stdout ) 574self .assertEqual (empty ["db" ]["sessions" ], []) 575self .assertEqual (empty ["old_kernels" ], []) 576self .assertEqual (empty ["old_backups" ], []) 577self .assertFalse (home .exists ()) 578home .mkdir () 579# No record also selects offline automatically, without creating one. 580absent = report (path = "" ) 581self .assertEqual (absent .returncode ,0 ,absent .stderr ) 582self .assertEqual (json .loads (absent .stdout ),empty ) 583self .assertEqual (list (home .iterdir ()), []) 584database = home / "albedo.sqlite" 585with closing (sqlite3 .connect (database ))as db : 586db .executescript ( 587"CREATE TABLE sessions(id TEXT); CREATE TABLE transcript(session TEXT,payload BLOB); CREATE TABLE images(data TEXT);" 588 ) 589db .execute ("INSERT INTO sessions VALUES(?)" , ("older" ,)) 590db .execute ("INSERT INTO transcript VALUES(?,?)" , ("older" ,b"history" )) 591db .execute ("INSERT INTO images VALUES(?)" , ("é🐈" ,)) 592db .commit () 593before = database .read_bytes () 594supported = report ("--offline" ) 595self .assertEqual (supported .returncode ,0 ,supported .stderr ) 596older = json .loads (supported .stdout ) 597self .assertEqual (older ["db" ]["sessions" ], [{"id" :"older" ,"bytes" :7 }]) 598self .assertEqual (older ["db" ]["images" ],2 ) 599tools = Path (directory )/ "tools" 600tools .mkdir () 601python = tools / "python3" 602python .write_text ("#!/bin/sh\nexit 99\n" ) 603python .chmod (0o700 ) 604without_python = report ( 605"--offline" ,path = str (tools )+ os .pathsep + app .env ["PATH" ] 606 ) 607self .assertEqual (without_python .returncode ,0 ,without_python .stderr ) 608self .assertEqual (json .loads (without_python .stdout ),older ) 609self .assertEqual (database .read_bytes (),before ) 610self .assertEqual ({path .name for path in home .iterdir ()}, {"albedo.sqlite" }) 611with closing (sqlite3 .connect (database ))as db : 612db .execute ( 613"ALTER TABLE transcript RENAME COLUMN payload TO unknown_payload" 614 ) 615db .commit () 616kernels = home / "kernels" 617kernels .mkdir () 618candidate = kernels / "unproven.state" 619candidate .write_bytes (b"retain" ) 620old = time .time ()- 31 * 24 * 60 * 60 621os .utime (candidate , (old ,old )) 622before = database .read_bytes () 623unsupported = report ("--offline" ) 624self .assertNotEqual (unsupported .returncode ,0 ) 625self .assertIn ("unsupported storage layout" ,unsupported .stderr ) 626self .assertEqual (unsupported .stdout ,"" ) 627self .assertEqual (database .read_bytes (),before ) 628self .assertEqual (candidate .read_bytes (),b"retain" ) 629self .assertFalse ((home / "daemon.json" ).exists ()) 630source = Path (directory )/ "wal-source.sqlite" 631with closing (sqlite3 .connect (source ))as writer : 632writer .execute ("PRAGMA journal_mode=WAL" ) 633writer .executescript ( 634"CREATE TABLE sessions(id TEXT); CREATE TABLE transcript(session TEXT,payload BLOB);" 635 ) 636writer .execute ("INSERT INTO sessions VALUES('wal-session')" ) 637writer .execute ( 638"INSERT INTO transcript VALUES('wal-session',?)" , 639 (b"committed in WAL" ,), 640 ) 641writer .commit () 642home = Path (directory )/ "wal-home" 643home .mkdir () 644 (home / "albedo.sqlite" ).write_bytes (source .read_bytes ()) 645 (home / "albedo.sqlite-wal" ).write_bytes ( 646Path (str (source )+ "-wal" ).read_bytes () 647 ) 648originals = {path .name :path .read_bytes ()for path in home .iterdir ()} 649self .assertNotIn ("albedo.sqlite-shm" ,originals ) 650wal_report = report ("--offline" ) 651self .assertEqual (wal_report .returncode ,0 ,wal_report .stderr ) 652self .assertEqual ( 653json .loads (wal_report .stdout )["db" ]["sessions" ], 654 [{"id" :"wal-session" ,"bytes" :16 }], 655 ) 656self .assertEqual ( 657 {path .name :path .read_bytes ()for path in home .iterdir ()},originals 658 ) 659 660# Only the private client home changes; the daemon is not stopped or mutated. 661def test_report_mode_selection_never_launches_or_hides_discovery_and_auth_failures ( 662self , 663 ): 664with Albedo (providers = {})as app ,tempfile .TemporaryDirectory ()as directory : 665home = Path (directory ) 666record = home / "daemon.json" 667 668def report (* options ): 669return subprocess .run ( 670 [str (ROOT / "cli/bin/albedo" ),"storage" ,* options ,"--json" ], 671env = dict ( 672app .env , 673ALBEDO_HOME = str (home ), 674ALBEDO_DAEMON = app .env ["ALBEDO_DAEMON" ], 675 ), 676cwd = ROOT , 677capture_output = True , 678text = True , 679timeout = 20 , 680 ) 681 682absent = report () 683self .assertEqual (absent .returncode ,0 ,absent .stderr ) 684empty = json .loads (absent .stdout ) 685self .assertEqual (list (home .iterdir ()), []) 686with socket .socket ()as endpoint : 687endpoint .bind (("127.0.0.1" ,0 )) 688refused_port = endpoint .getsockname ()[1 ] 689stale = dict (app .connection ,pid = 2147483647 ,port = refused_port ) 690record .write_text (json .dumps (stale )) 691stale_bytes = record .read_bytes () 692stale_result = report () 693self .assertEqual (stale_result .returncode ,0 ,stale_result .stderr ) 694self .assertEqual ( 695json .loads (stale_result .stdout ),dict (empty ,other = len (stale_bytes )) 696 ) 697self .assertEqual (record .read_bytes (),stale_bytes ) 698for content in ( 699"not JSON" , 700json .dumps (dict (app .connection ,token = "wrong-token" )), 701 ): 702with self .subTest (record = content ): 703record .write_text (content ) 704failed = report () 705self .assertNotEqual (failed .returncode ,0 ) 706self .assertEqual (failed .stdout ,"" ) 707self .assertEqual (record .read_text (),content ) 708explicit = report ("--offline" ) 709self .assertEqual (explicit .returncode ,0 ,explicit .stderr ) 710# The discovery record itself is a regular local file. 711local = json .loads (explicit .stdout ) 712self .assertEqual (local ["other" ],len (content .encode ())) 713self .assertEqual (local ["db" ]["sessions" ], []) 714self .assertEqual (record .read_text (),content ) 715self .assertEqual ( 716 {path .name for path in home .iterdir ()}, {"daemon.json" } 717 ) 718 719# The diagnostic and its snapshot use private homes, tools, and scratch paths. 720@ unittest . skipUnless (hasattr( os , "pidfd_open" ), "requires Linux pidfds" ) 721def test_canceling_offline_diagnostic_reaps_child_and_removes_private_snapshot ( 722self , 723 ): 724with Albedo (providers = {})as app ,tempfile .TemporaryDirectory ()as directory : 725root = Path (directory ) 726home = root / "home" 727home .mkdir () 728database = home / "albedo.sqlite" 729with closing (sqlite3 .connect (database ))as db : 730db .executescript ( 731"CREATE TABLE sessions(id TEXT); CREATE TABLE transcript(session TEXT,payload BLOB);" 732 ) 733db .commit () 734before = database .read_bytes () 735scratch = root / "scratch" 736scratch .mkdir () 737ready = root / "ready" 738os .mkfifo (ready ) 739descriptor = os .open (ready ,os .O_RDWR | os .O_NONBLOCK ) 740self .addCleanup (os .close ,descriptor ) 741tools = root / "tools" 742tools .mkdir () 743helper = tools / "albedo-daemon" 744helper .write_text ( 745f"#! { sys . executable } \n" 746"import os, sys, threading\n" 747"from pathlib import Path\n" 748"snapshot = Path(sys.argv[4])\n" 749"(snapshot / 'albedo.sqlite').write_bytes(b'partial snapshot')\n" 750f"with open( { str ( ready )!r } , 'w') as pipe:\n" 751" pipe.write(str(os.getpid()) + '\\n')\n" 752"threading.Event().wait()\n" 753 ) 754helper .chmod (0o700 ) 755for death in (signal .SIGINT ,signal .SIGTERM ): 756with self .subTest (signal = death ): 757command = subprocess .Popen ( 758 [ 759str (ROOT / "cli/bin/albedo" ), 760"storage" , 761"--offline" , 762"--json" , 763 ], 764env = dict ( 765app .env , 766ALBEDO_HOME = str (home ), 767TMPDIR = str (scratch ), 768ALBEDO_DAEMON = str (helper ), 769 ), 770cwd = ROOT , 771stdout = subprocess .PIPE , 772stderr = subprocess .PIPE , 773 ) 774self .addCleanup (self .close_helper ,command ) 775readable ,_ ,_ = select .select ([descriptor ], [], [],20 ) 776self .assertEqual ( 777readable , 778 [descriptor ], 779"diagnostic never created its private snapshot" , 780 ) 781child = int (os .read (descriptor ,1024 )) 782child_descriptor = os .pidfd_open (child ) 783try : 784self .assertEqual (len (list (scratch .iterdir ())),1 ) 785command .send_signal (death ) 786stdout ,_ = command .communicate (timeout = 10 ) 787self .assertNotEqual (command .returncode ,0 ) 788self .assertEqual (stdout ,b"" ) 789readable ,_ ,_ = select .select ([child_descriptor ], [], [],10 ) 790self .assertEqual ( 791readable , 792 [child_descriptor ], 793"diagnostic child survived cancellation" , 794 ) 795self .assertEqual ( 796list (scratch .iterdir ()), 797 [], 798"private snapshot leaked after cancellation" , 799 ) 800self .assertEqual (database .read_bytes (),before ) 801self .assertEqual ( 802 {path .name for path in home .iterdir ()}, {"albedo.sqlite" } 803 ) 804finally : 805os .close (child_descriptor ) 806self .close_helper (command )