okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
efc0462
main
1"""Busy-host replies and reading an in-flight job notice are races the daemon E2E wake test cannot reliably force.""" 2 3import json 4import os 5import select 6import struct 7import subprocess 8import sys 9import tempfile 10import time 11import unittest 12from pathlib import Path 13 14ROOT = Path (__file__ ).resolve ().parents [2 ] 15KERNEL = ROOT / "priv" / "python" / "albedo_kernel.py" 16 17sys .path .insert (0 ,str (ROOT / "test" )) 18import scratch # noqa: E402 19 20# Temporary workspaces and homes go under this run's scratch directory, which 21# is removed at exit. 22scratch .claim ("harness" ) 23MAX_FRAME = 8 * 1024 * 1024 24RETRY_WINDOW = 3.2 # NOTICE_RETRY plus slack; must not mistake a retry for silence 25 26 27class Owner : 28"""A minimal owner that answers host calls and buffers every other frame.""" 29 30def __init__ (self ,modules ,env = None ): 31self .workspace = tempfile .mkdtemp (prefix = "albedo-wake-" ) 32environment = dict (os .environ ) 33environment .update (env or {}) 34self .process = subprocess .Popen ( 35 [sys .executable ,"-u" ,str (KERNEL ),json .dumps (modules )], 36stdin = subprocess .PIPE , 37stdout = subprocess .PIPE , 38stderr = subprocess .PIPE , 39cwd = self .workspace , 40env = environment , 41bufsize = 0 , 42 ) 43self .buffered = [] 44 45def close (self ): 46self .send ({"type" :"shutdown" }) 47self .process .wait (timeout = 10 ) 48for stream in (self .process .stdin ,self .process .stdout ,self .process .stderr ): 49assert stream is not None 50stream .close () 51self .buffered .clear () 52 53def send (self ,message ): 54data = json .dumps (message ).encode () 55assert self .process .stdin is not None 56self .process .stdin .write (struct .pack (">I" ,len (data ))+ data ) 57self .process .stdin .flush () 58 59def recv (self ,timeout = 0.05 ): 60"""One frame, or None when the kernel stays quiet for the window. 61stdout is unbuffered: a frame read ahead into a buffer is invisible to 62select(), and would stall until the next one arrives.""" 63assert self .process .stdout is not None 64ready ,_ ,_ = select .select ([self .process .stdout ], [], [],timeout ) 65if not ready : 66return None 67size = struct .unpack (">I" ,self .read_exactly (4 ))[0 ] 68assert size <= MAX_FRAME ,"control frame exceeds the ceiling" 69return json .loads (self .read_exactly (size )) 70 71def read_exactly (self ,size ): 72assert self .process .stdout is not None 73data = b"" 74while len (data )< size : 75chunk = self .process .stdout .read (size - len (data )) 76assert chunk ,"kernel closed its control stream" 77data += chunk 78return data 79 80def wait_for (self ,predicate ,timeout = 10.0 ): 81deadline = time .monotonic ()+ timeout 82while time .monotonic ()< deadline : 83for index ,frame in enumerate (self .buffered ): 84if predicate (frame ): 85return self .buffered .pop (index ) 86frame = self .recv (timeout = max (0.05 ,deadline - time .monotonic ())) 87if frame is not None : 88self .buffered .append (frame ) 89raise AssertionError ( 90f"no matching frame; buffered: { [ f . get ( 'type' ) for f in self . buffered ] } " 91 ) 92 93def expect_no_call (self ,method ,window = RETRY_WINDOW ): 94deadline = time .monotonic ()+ window 95while time .monotonic ()< deadline : 96try : 97frame = self .wait_for ( 98lambda f :f .get ("type" )== "call" and f .get ("method" )== method , 99timeout = max (0.05 ,deadline - time .monotonic ()), 100 ) 101except AssertionError : 102return 103raise AssertionError (f"unexpected { method } call: { frame } " ) 104 105def invoke (self ,call_id ,** frame ): 106self .send ({"type" :"invoke" ,"id" :call_id ,** frame }) 107while True : 108reply = self .wait_for ( 109lambda f :f .get ("id" )== call_id and f ["type" ]== "invoked" 110 ) 111if reply .get ("ok" )is True or "error" in reply : 112return reply 113 114 115class WakeProtocolTest (unittest .TestCase ): 116def setUp (self ): 117self .owner = Owner (["run" ]) 118self .addCleanup (self .owner .close ) 119self .assertEqual ( 120self .owner .wait_for (lambda f :f .get ("type" )== "ready" )["type" ],"ready" 121 ) 122 123def start_job (self ,* argv ): 124reply = self .owner .invoke ("j1" ,name = "run" ,args = list (argv )) 125self .assertTrue (reply ["ok" ]) 126return reply ["handle" ] 127 128def wait_job_done (self ): 129"""Wait on the raw completion frame; awaiting the job would retire the wake.""" 130start = self .owner .wait_for (lambda f :f .get ("type" )== "job_start" ) 131done = self .owner .wait_for ( 132lambda f :f .get ("type" )== "job" and f .get ("id" )== start ["id" ] 133 ) 134return done 135 136def test_a_busy_session_is_retried_and_a_refusal_gives_up (self ): 137self .start_job ("echo" ,"retry-echo" ) 138self .wait_job_done () 139# Busy is retried, not dropped; a permanent refusal ends the attempts. 140frame = self .owner .wait_for (lambda f :f .get ("type" )== "call" ) 141self .owner .send ( 142 { 143"type" :"reply" , 144"id" :frame ["id" ], 145"value" : {"ok" :False ,"code" :"busy" ,"message" :"session is busy" }, 146 } 147 ) 148retry = self .owner .wait_for ( 149lambda f :f .get ("type" )== "call" and f .get ("method" )== "jobs.completed" , 150timeout = RETRY_WINDOW , 151 ) 152self .owner .send ( 153 { 154"type" :"reply" , 155"id" :retry ["id" ], 156"value" : {"ok" :False ,"code" :"unavailable" ,"message" :"no session" }, 157 } 158 ) 159self .owner .expect_no_call ("jobs.completed" ) 160 161def test_jobs_owed_when_a_busy_session_frees_share_one_notice (self ): 162self .start_job ("echo" ,"first-echo" ) 163self .wait_job_done () 164frame = self .owner .wait_for (lambda f :f .get ("type" )== "call" ) 165self .assertEqual (len (frame ["args" ]["jobs" ]),1 ) 166self .start_job ("echo" ,"second-echo" ) 167self .wait_job_done () 168self .owner .send ( 169 { 170"type" :"reply" , 171"id" :frame ["id" ], 172"value" : {"ok" :False ,"code" :"busy" ,"message" :"session is busy" }, 173 } 174 ) 175retry = self .owner .wait_for ( 176lambda f :f .get ("type" )== "call" and f .get ("method" )== "jobs.completed" , 177timeout = RETRY_WINDOW , 178 ) 179self .assertEqual (len (retry ["args" ]["jobs" ]),2 ) 180self .assertIn ("2 background jobs finished" ,retry ["args" ]["text" ]) 181self .assertIn ("second-echo" ,retry ["args" ]["display" ]) 182self .owner .send ( 183 {"type" :"reply" ,"id" :retry ["id" ],"value" : {"ok" :True ,"value" :None }} 184 ) 185self .owner .expect_no_call ("jobs.completed" ) 186 187def test_a_read_result_retires_a_pending_notice (self ): 188handle = self .start_job ("echo" ,"read-echo" ) 189self .wait_job_done () 190# The notice is in flight (busy) when the result is read; the retry must stop. 191frame = self .owner .wait_for (lambda f :f .get ("type" )== "call" ) 192self .owner .send ( 193 { 194"type" :"reply" , 195"id" :frame ["id" ], 196"value" : {"ok" :False ,"code" :"busy" ,"message" :"session is busy" }, 197 } 198 ) 199tail = self .owner .invoke ("t1" ,target = {"handle" :handle },name = "tail" ) 200self .assertIn ("read-echo" ,tail ["value" ]) 201self .owner .expect_no_call ("jobs.completed" ) 202 203 204if __name__ == "__main__" : 205unittest .main ()