diff --git a/src/run.py b/src/run.py index 79e95aa..b647271 100644 --- a/src/run.py +++ b/src/run.py @@ -210,20 +210,26 @@ class Launcher: child.wait() def wait_child(self, child, trial: bool): - """Код выхода и признак «пробный период истёк». В пробный период ждём ok не дольше trial_seconds.""" + """(код выхода, «пробный период истёк», «версия подтверждена»). В пробный период ждём ok не дольше trial_seconds. + Подтверждение — любое состояние update.json, кроме trial этой же версии, пока агент жив: запускатель сам + update.json при живом агенте не пишет, значит это сделал подтвердивший агент (ok, затем rejected/staged для + следующей версии — I-1).""" start = time.monotonic() + st0 = read_state(self.state_path) if trial else None + trial_sha = (st0 or {}).get("sha256") + confirmed = False while True: try: - return child.wait(1), False + return child.wait(1), False, confirmed except subprocess.TimeoutExpired: pass - if trial: + if trial and not confirmed: st = read_state(self.state_path) - if st and st.get("state") == "ok": - trial = False + if st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha): + confirmed = True elif time.monotonic() - start >= self.trial_seconds: self.terminate(child) - return child.returncode, True + return child.returncode, True, False def pause(self, seconds: float) -> bool: """Пауза перед перезапуском; False — остановлены во время паузы.""" @@ -249,16 +255,21 @@ class Launcher: if self.stopping: # сигнал пришёл между запусками — нового агента не запускаем (M-2) return code trial = self.in_trial() + trial_sha = (read_state(self.state_path) or {}).get("sha256") if trial else None started = time.monotonic() self.interrupted = False # Ctrl-C относится к этому запуску агента self.child = self.spawn() if self.stopping: # SIGTERM между Popen и присваиванием child до агента не дошёл self.child.send_signal(signal.SIGTERM) - code, timed_out = self.wait_child(self.child, trial) + code, timed_out, confirmed = self.wait_child(self.child, trial) self.child = None uptime = time.monotonic() - started st = read_state(self.state_path) ok = bool(st and st.get("state") == "ok") + if trial and not (ok or confirmed) and st and not (st.get("state") == "trial" and st.get("sha256") == trial_sha) \ + and sha256_file(self.agent) == trial_sha: + confirmed = True # ok и следующая запись агента случились между опросами (I-1) + ok = ok or confirmed reason = "агент упал (код %s)" % code if timed_out and ok: # ответ claim пришёл в последнюю секунду (M-4, design 3) log("подтверждение пришло в последний момент — отката нет") diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 6d2f757..4b69f27 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -85,6 +85,20 @@ V6_OK_ON_TERM = fake("v6", ''' ''') +# v6 подтвердился (ok), через delay с записал rejected для v10 с другим sha (как Updater._reject) и упал с кодом 1. +V6_OK_THEN_REJECT_NEXT = fake("v6", ''' + import time + n = len(open(os.path.join(HERE, "runs.log")).read().split()) + if n > 2: + human_stop() + st = state() + write(**dict(st, state="ok")) + time.sleep(DELAY) + write(state="rejected", from_version=6, to_version=10, sha256="чужой", error="отвергнута") + sys.exit(1) +''') + + class LauncherTest(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() @@ -364,6 +378,23 @@ class LauncherTest(unittest.TestCase): self.assertEqual(self.runs(), ["v5", "v6", "v6"]) self.assertEqual(self.pauses, []) # перезапуск без паузы + def _confirmed_then_rejected_next(self, delay): + self.put("agent.py", V5_UPDATES) + self.next_version(V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay))) + self.assertEqual(self.l.run(), 0) + self.assertEqual(self.read("agent.py"), V6_OK_THEN_REJECT_NEXT.replace("DELAY", repr(delay))) + self.assertEqual(self.runs(), ["v5", "v6", "v6"]) + st = self.state() + self.assertEqual((st["state"], st["to_version"]), ("rejected", 10)) # не rolled_back: v10 не заблокирована + + def test_confirmed_then_next_rejected_no_rollback(self): + """I-1: агент записал ok, потом сам перезаписал update.json (rejected для v10) и упал — v6 здоровая, откат не нужен.""" + self._confirmed_then_rejected_next(2.5) + + def test_confirmed_and_rejected_within_poll_no_rollback(self): + """I-1: ok и rejected записаны быстрее, чем запускатель опрашивает файл раз в секунду.""" + self._confirmed_then_rejected_next(0) + def test_locked_in_trial_no_rollback(self): """M-7: журнал держит осиротевший агент — код 73 не откатывает новую версию.""" v6 = fake("v6", '''