okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
5bdc0ee
main
1"""A stale kernel is swapped for a current one, namespace included. 2 3After a daemon restart the reattached kernel may run an older python bundle, 4have booted with another module set than its session has now, or speak another 5session protocol. At the session's next idle moment it is replaced by a current 6kernel, its variables carried through a snapshot and a restore. A live job 7holds an older bundle or module set until it ends; a kernel on another protocol 8goes at once, its outbox dropped rather than replayed, carrying nothing when 9its snapshot fails, and its process group ends either way. 10""" 11 12import json 13import os 14from pathlib import Path 15import sqlite3 16import time 17import unittest 18 19import harness 20from harness import alive 21from harness import Albedo ,Provider ,exclusive ,python ,text 22from kernel_support import MARKER ,ModelCells ,editable_daemon 23 24 25class Swaps (ModelCells ,unittest .TestCase ): 26"""Cells through model turns against one session, on a daemon that may run 27an editable copy of the python bundle.""" 28 29bundle :Path 30 31def boot (self ,* ,editable ): 32self .cells = [] 33 34def script (request ): 35if request ["input" ][- 1 ].get ("role" )== "user" and self .cells : 36return python (self .cells .pop (0 )) 37return text ("ok" ) 38 39def prepare (app ): 40launcher ,self .bundle = editable_daemon (app .root ) 41app .env ["ALBEDO_DAEMON" ]= str (launcher ) 42 43self .provider = Provider (script ) 44self .addCleanup (self .provider .close ) 45self .app = Albedo ( 46self .provider ,protocol = "responses" ,prepare = prepare if editable else None 47 ) 48self .app .__enter__ () 49self .addCleanup (self .app .__exit__ ,None ,None ,None ) 50self .session = self .app .session () 51 52def kernel (self ): 53with self .app .api (f"/sessions/ { self . session } ?tail=0" )as response : 54return json .load (response )["kernel" ] 55 56def stale (self ): 57return self .kernel ()["stale" ] 58 59def held_job (self ): 60"""A gate and the cell code for a job that runs until the gate is 61released: the test decides when the job ends, not a sleep that a slow 62restart under load can outlast.""" 63gate = self .app .workspace / "job-gate" 64os .mkfifo (gate ) 65self .addCleanup (harness .release_fifo ,gate ) 66program = f"open( { str ( gate )!r } , 'rb').read(1)" 67return gate ,f"import sys\nj = run(sys.executable, '-c', { program !r } )" 68 69def end_job (self ,gate ): 70harness .wait_until ( 71lambda :harness .release_fifo (gate ),10 ,"the held job never opened its gate" 72 ) 73 74def wait_until_current (self ): 75deadline = time .monotonic ()+ 30 76while self .stale ()and time .monotonic ()< deadline : 77# The job's wake is the idle moment that swaps the kernel. 78time .sleep (0.2 ) 79self .app .idle (self .session ) 80 81 82# exclusive: boots an editable Python bundle and restarts the daemon 83@exclusive 84class KernelUpgradeTests (Swaps ): 85def setUp (self ): 86self .boot (editable = True ) 87 88def edit_bundle (self ): 89with (self .bundle / "albedo_bundle.py" ).open ("a" )as source : 90source .write (MARKER ) 91 92def probe (self ): 93return self .cell ( 94"import albedo_bundle, os\n(survivor, getattr(albedo_bundle, 'MARKER', None), os.getpid())" 95 ) 96 97def test_restart_onto_a_new_bundle_swaps_the_kernel_and_keeps_variables (self ): 98first ,_ = self .cell ("import os\nsurvivor = 41\nos.getpid()" ) 99self .edit_bundle () 100self .app .restart () 101swapped ,prompt = self .probe () 102self .assertEqual (swapped .get ("status" ),"ok" ,swapped ) 103survivor ,marker ,pid = swapped ["value" ].strip ("()" ).split (", " ) 104self .assertEqual ((survivor ,marker ), ("41" ,"'new bundle'" ),swapped ) 105self .assertNotEqual (pid ,first ["value" ]) 106self .assertIn ("The python kernel was upgraded to the new python bundle" ,prompt ) 107# the restored names include `os`, re-imported from source 108self .assertRegex (prompt ,r"Restored: [^.]*\bsurvivor\b" ) 109self .assertFalse (self .stale ()) 110 111def test_a_live_job_holds_the_old_kernel_until_it_ends (self ): 112gate ,job = self .held_job () 113self .cell (f"import os\nsurvivor = 41\n { job } \nos.getpid()" ) 114self .edit_bundle () 115self .app .restart () 116held ,_ = self .probe () 117self .assertIn ("None" ,held ["value" ],"the old kernel must still run" ) 118self .assertTrue (self .stale ()) 119self .end_job (gate ) 120self .wait_until_current () 121swapped ,_ = self .probe () 122self .assertIn ("'new bundle'" ,swapped ["value" ]) 123self .assertTrue (swapped ["value" ].startswith ("(41," ),swapped ) 124 125def test_kernel_upgrade_forces_the_swap_past_a_live_job (self ): 126self .cell ("import os\nsurvivor = 41\nj = run('sleep', '60')\nos.getpid()" ) 127self .edit_bundle () 128self .app .restart () 129with self .app .api ( 130f"/sessions/ { self . session } /kernel/upgrade" , 131 {}, 132 )as response : 133result = json .load (response ) 134self .assertEqual (len (result ["stopped_jobs" ]),1 ,result ) 135swapped ,_ = self .probe () 136self .assertIn ("'new bundle'" ,swapped ["value" ]) 137self .assertTrue (swapped ["value" ].startswith ("(41," ),swapped ) 138self .assertFalse (self .stale ()) 139 140 141# exclusive: restarts the daemon and changes global extension enablement 142@exclusive 143class ModuleSkewTests (Swaps ): 144"""The session's module set changes while the daemon is down: disabling 145skills drops the `skills` module the recorded kernel booted with.""" 146 147def setUp (self ): 148self .boot (editable = False ) 149 150def drop_skills (self ,app ): 151settings = json .loads ((app .home / "extensions.json" ).read_text ()) 152settings .setdefault ("enabled" , {})["skills" ]= False 153 (app .home / "extensions.json" ).write_text (json .dumps (settings )) 154 155def probe (self ): 156return self .cell ("import os\n(survivor, 'skills' in globals(), os.getpid())" ) 157 158def test_a_changed_module_set_swaps_the_kernel_and_keeps_variables (self ): 159first ,_ = self .cell ( 160"import os\nsurvivor = 41\n('skills' in globals(), os.getpid())" 161 ) 162self .assertTrue (first ["value" ].startswith ("(True," ),first ) 163self .app .restart (prepare = self .drop_skills ) 164swapped ,prompt = self .probe () 165self .assertEqual (swapped .get ("status" ),"ok" ,swapped ) 166survivor ,skills ,pid = swapped ["value" ].strip ("()" ).split (", " ) 167self .assertEqual ((survivor ,skills ), ("41" ,"False" ),swapped ) 168self .assertNotEqual (pid ,first ["value" ].strip ("()" ).split (", " )[1 ]) 169self .assertIn ( 170"The python kernel was upgraded to the session's new extension modules" , 171prompt , 172 ) 173self .assertRegex (prompt ,r"Restored: [^.]*\bsurvivor\b" ) 174self .assertFalse (self .stale ()) 175 176def test_a_live_job_holds_the_old_module_set_until_it_ends (self ): 177gate ,job = self .held_job () 178self .cell (f"import os\nsurvivor = 41\n { job } " ) 179self .app .restart (prepare = self .drop_skills ) 180held ,_ = self .probe () 181self .assertTrue (held ["value" ].startswith ("(41, True," ),held ) 182self .assertIn ( 183"modules" , 184 [reason ["code" ]for reason in self .kernel ()["staleness_reasons" ]], 185 ) 186self .end_job (gate ) 187self .wait_until_current () 188swapped ,_ = self .probe () 189self .assertTrue (swapped ["value" ].startswith ("(41, False," ),swapped ) 190 191 192# exclusive: restarts the daemon and edits its kernel outbox offline 193@exclusive 194class ProtocolSkewTests (Swaps ): 195"""The kernel's session layer is told to speak protocol 2 from inside a 196cell, so the hello it gives the restarted daemon differs from the 197bridge's protocol 1, as an older kernel's would; its bundle stays current.""" 198 199def setUp (self ): 200self .boot (editable = False ) 201 202def start (self ): 203"""Variables and a long job in the old kernel: its pid and the job's.""" 204started ,_ = self .cell ( 205"import albedo_link, asyncio, os\nalbedo_link.PROTOCOL = 2\n" 206"survivor = 41\nj = run('sleep', '60')\n" 207"await asyncio.sleep(0.5)\n(os.getpid(), j.process.pid)" 208 ) 209kernel ,job = (int (part )for part in started ["value" ].strip ("()" ).split ("," )) 210return kernel ,job 211 212def restart_current (self ,* ,unsaveable = False ): 213"""Restart onto protocol 1 with a frame for the old kernel waiting in 214the daemon's outbox; it would leave `replayed` behind if it ran.""" 215marker = self .app .root / "replayed" 216 217def prepare (app ): 218with sqlite3 .connect (app .home / "albedo.sqlite" )as db : 219kernel ,run_dir ,seq = db .execute ( 220"SELECT kernel, run_dir, out_seq + 1 FROM kernel_links WHERE session=?" , 221 (self .session ,), 222 ).fetchone () 223frame = { 224"type" :"execute" , 225"id" :"left-in-the-outbox" , 226"code" :f"open( { str ( marker )!r } , 'w').close()" , 227"durable" :False , 228"max_edge" :0 , 229 } 230db .execute ( 231"INSERT INTO kernel_outbox (session, kernel, seq, frame) VALUES (?,?,?,?)" , 232 (self .session ,kernel ,seq ,json .dumps (frame )), 233 ) 234db .execute ( 235"UPDATE kernel_links SET out_seq=? WHERE kernel=?" , (seq ,kernel ) 236 ) 237if unsaveable : 238# A directory where the snapshot is written: the save fails. 239 (Path (run_dir )/ "namespace.state" / "taken" ).mkdir (parents = True ) 240 241self .app .restart (prepare = prepare ) 242return marker 243 244def probe (self ): 245return self .cell ("import os\n(globals().get('survivor'), os.getpid())" ) 246 247def assert_gone (self ,marker ,* pids ): 248"""The old kernel and its job ended, its outbox dropped unreplayed.""" 249deadline = time .monotonic ()+ 15 250while any (alive (pid )for pid in pids )and time .monotonic ()< deadline : 251time .sleep (0.1 ) 252self .assertFalse ([pid for pid in pids if alive (pid )],"the old kernel lives on" ) 253self .assertFalse (marker .exists (),"the old outbox was replayed" ) 254with sqlite3 .connect (self .app .home / "albedo.sqlite" )as db : 255 (left ,)= db .execute ( 256"SELECT count(*) FROM kernel_outbox" 257" WHERE CAST(frame AS TEXT) LIKE '%left-in-the-outbox%'" 258 ).fetchone () 259self .assertEqual (left ,0 ) 260 261def test_another_protocol_swaps_past_live_jobs_and_drops_the_outbox (self ): 262kernel ,job = self .start () 263marker = self .restart_current () 264swapped ,prompt = self .probe () 265self .assertEqual (swapped .get ("status" ),"ok" ,swapped ) 266survivor ,pid = swapped ["value" ].strip ("()" ).split (", " ) 267self .assertEqual (survivor ,"41" ,swapped ) 268self .assertNotEqual (int (pid ),kernel ) 269self .assertIn ( 270"The python kernel was upgraded to the current kernel protocol" ,prompt 271 ) 272self .assertFalse (self .stale ()) 273self .assert_gone (marker ,kernel ,job ) 274 275def test_a_bundle_out_of_step_with_the_daemon_refuses_to_boot (self ): 276"""A python bundle on another protocol than the daemon's own makes 277every fresh kernel stale at birth: the boot fails and says why, 278instead of swapping one fresh kernel for another forever.""" 279 280def prepare (app ): 281launcher ,bundle = editable_daemon (app .root ) 282app .env ["ALBEDO_DAEMON" ]= str (launcher ) 283link = bundle / "albedo_link.py" 284link .write_text (link .read_text ().replace ("PROTOCOL = 1" ,"PROTOCOL = 2" ,1 )) 285 286self .app .restart (prepare = prepare ) 287started = time .monotonic () 288with self .app .prompt (self .session ,"run it" )as response : 289input_id = json .load (response )["id" ] 290deadline = started + 10 291while time .monotonic ()< deadline : 292with self .app .api ( 293f"/sessions/ { self . session } /inputs/ { input_id } " 294 )as response : 295receipt = json .load (response ) 296if receipt ["blocking_reason" ]: 297break 298time .sleep (0.05 ) 299else : 300self .fail ("incompatible kernel did not expose a blocked input" ) 301self .assertEqual (receipt ["delivery" ],"pending" ) 302self .assertIn ("out of step" ,receipt ["blocking_reason" ]["detail" ],receipt ) 303self .assertIn ("protocol" ,receipt ["blocking_reason" ]["detail" ]) 304with self .app .api (f"/sessions/ { self . session } ?tail=0" )as response : 305self .assertEqual (json .load (response )["kernel" ]["state" ],"none" ) 306 307def test_another_protocol_whose_snapshot_fails_is_swapped_carrying_nothing (self ): 308kernel ,job = self .start () 309marker = self .restart_current (unsaveable = True ) 310swapped ,prompt = self .probe () 311self .assertEqual (swapped .get ("status" ),"ok" ,swapped ) 312self .assertTrue (swapped ["value" ].startswith ("(None," ),swapped ) 313self .assertIn ("No variables were restored" ,prompt ) 314self .assertIn ("Not carried: namespace" ,prompt ) 315self .assertFalse (self .stale ()) 316self .assert_gone (marker ,kernel ,job ) 317 318 319if __name__ == "__main__" : 320unittest .main ()