mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(harness): complete Lark device auth via a single-flight login run The web authorization polling loop reused the request wait budget as the lark-cli auth login subprocess lifetime: each /lark/auth/complete poll ran the CLI with subprocess timeout=wait_timeout_seconds (8s from the frontend) and killed it on timeout. The login transaction is not atomic — poll, redeem the single-use device code, fetch user info, then persist credentials — so a kill after redemption but before persistence stranded the flow, and the next poll with the same code failed with 'device_code is invalid'. Own the login process by the flow generation instead: the first poll spawns one background run per (user_id, generation) and later polls attach to it; wait_timeout_seconds only bounds the caller's wait (unchanged 504 contract) and never terminates the CLI. Advancing the generation or re-issuing a device code abandons and kills the orphaned process, and a finished run's outcome stays attachable for 120s so late polls return the same result instead of re-consuming the redeemed code. * fix(lark): tolerate vanished credential-tree entries and bound owner-alive grace Publication could kill the login worker with an unhandled FileNotFoundError when a concurrently-written temp file vanished between iterdir() and lstat(), leaving the marker live but no result file ever published; every foreign poll then blocked 504 until the 600 s attempt bound, and could respawn an already-redeemed code. Also, a bare-pid owner_alive check trusted for the full 600 s let a recycled-pid or crashed-thread marker outlive any process that could still publish. - _credential_tree_path_kind returns "gone" on FileNotFoundError so the walk skips vanished entries instead of aborting; the same tolerance is applied to iterdir(). - _write_lark_login_result_once treats the re-harden as best-effort: a filesystem hiccup is logged and publication continues so the marker sweep still runs. - The marker now records owner_pid_started_at and _read_live_lark_login_marker_locked only keeps an owner-alive marker within a 10 s publication grace, so a stale marker is swept and the flow respawns instead of wedging every foreign poll. Regression: test_walk_and_harden_tolerates_a_vanished_entry, test_write_result_once_continues_when_hardening_fails, test_abandoned_marker_with_dead_owner_is_swept_within_grace. Focused suites: 139 passed, 12 skipped. * fix(lark): harden surviving credentials through filesystem churn Atomic login publication can remove a descendant after lstat but before chmod. Catch FileNotFoundError around the entire per-entry walk so that lstat, chmod and directory enumeration tolerate those disappearances. A vanished root, permission error or symlink remains a hard failure. Exercise real removals at each boundary and concurrent result publication; assert that injection ran and surviving directories/files reach 0700/0600. The regressions detect a skipped walk, swallowed permission failures and loss of directory-churn tolerance in isolated mutation runs. * fix(lark): retain login ownership through durable completion Browser approval can take longer than the publication grace. Start that grace at the first observed CLI exit, then reject abandoned attempts without retrying a device code that may already have been redeemed. Serialize atomic result publication under the credential lock. Preserve published results through cleanup failures and retain terminal failures on the marker when result storage fails. Never bypass credential hardening. Reserve storage before spawning and roll back failed marker, process or thread startup by reaping the CLI, clearing the registry and waking waiters. Add process/filesystem failure regressions, a long-login two-worker case, and strict unhandled-thread gates. Fix invocation logging in both workers and reap forked test CLIs after the parent observes their launch. Document the authorization lifecycle and required regression assertions. Validated the reproduced failures and ten isolated mutations; final focused, blocking-I/O, full-suite and independent review details are in PR #7182.
321 lines
14 KiB
Python
321 lines
14 KiB
Python
"""Offline failure injection at the login process and filesystem boundaries."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from deerflow.config import paths as paths_module
|
|
from deerflow.config.paths import Paths
|
|
from deerflow.integrations import lark_cli
|
|
|
|
pytestmark = pytest.mark.filterwarnings("error::pytest.PytestUnhandledThreadExceptionWarning")
|
|
|
|
|
|
class _LoginProcess:
|
|
pid = None
|
|
|
|
def __init__(self):
|
|
self.released = threading.Event()
|
|
self.killed = False
|
|
|
|
def communicate(self, timeout=None):
|
|
assert self.released.wait(10), "test did not release the login process"
|
|
return "{}", ""
|
|
|
|
@property
|
|
def returncode(self):
|
|
return -9 if self.killed else 0
|
|
|
|
def poll(self):
|
|
return self.returncode if self.released.is_set() else None
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
self.released.set()
|
|
|
|
|
|
@pytest.fixture
|
|
def login_flow(monkeypatch, tmp_path):
|
|
monkeypatch.setattr(paths_module, "_paths", Paths(base_dir=tmp_path / "home"))
|
|
monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "synthetic-lark-cli")
|
|
monkeypatch.setattr(lark_cli, "LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS", 0)
|
|
spawned = []
|
|
|
|
def spawn(*_args, **_kwargs):
|
|
process = _LoginProcess()
|
|
spawned.append(process)
|
|
return process
|
|
|
|
monkeypatch.setattr(lark_cli, "_spawn_lark_login_process", spawn)
|
|
with lark_cli._lark_credential_lock("alice"):
|
|
generation = lark_cli._advance_lark_flow_generation_locked("alice")
|
|
yield generation, spawned
|
|
for process in spawned:
|
|
process.kill()
|
|
lark_cli._clear_lark_login_runs_for_tests()
|
|
|
|
|
|
@pytest.mark.parametrize("operation", ["mkstemp", "write", "chmod", "publish"])
|
|
def test_result_io_failure_is_terminal_for_local_and_foreign_polls(monkeypatch, login_flow, operation):
|
|
generation, spawned = login_flow
|
|
with pytest.raises(TimeoutError):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
injected = threading.Event()
|
|
|
|
def is_result_temp(path):
|
|
return Path(path).name.startswith("..login-result-")
|
|
|
|
def fail():
|
|
injected.set()
|
|
raise OSError(errno.EIO, "synthetic result I/O failure")
|
|
|
|
if operation == "mkstemp":
|
|
original = lark_cli.tempfile.mkstemp
|
|
|
|
def mkstemp(*args, **kwargs):
|
|
if kwargs.get("prefix", "").startswith("..login-result-"):
|
|
fail()
|
|
return original(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(lark_cli.tempfile, "mkstemp", mkstemp)
|
|
elif operation == "publish":
|
|
|
|
def fail_publication(original):
|
|
def publish(source, destination, *args, **kwargs):
|
|
if Path(destination).name.startswith(".login-result-"):
|
|
fail()
|
|
return original(source, destination, *args, **kwargs)
|
|
|
|
return publish
|
|
|
|
monkeypatch.setattr(os, "link", fail_publication(os.link))
|
|
monkeypatch.setattr(os, "replace", fail_publication(os.replace))
|
|
else:
|
|
name = "write_text" if operation == "write" else "chmod"
|
|
original = getattr(Path, name)
|
|
|
|
def file_operation(path, *args, **kwargs):
|
|
if is_result_temp(path):
|
|
fail()
|
|
return original(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, name, file_operation)
|
|
|
|
spawned[0].released.set()
|
|
with pytest.raises(ValueError, match="Restart the authorization flow"):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=5)
|
|
assert injected.is_set(), "failure injection was not reached"
|
|
|
|
# A fresh worker has no registry entry; the shared terminal receipt must
|
|
# reject this code instead of starting a second login.
|
|
with lark_cli._LARK_LOGIN_RUNS_LOCK:
|
|
run = lark_cli._LARK_LOGIN_RUNS.pop(("alice", generation))
|
|
run.thread.join(5)
|
|
assert not run.thread.is_alive()
|
|
with pytest.raises(ValueError, match="Restart the authorization flow"):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
assert len(spawned) == 1
|
|
|
|
|
|
def test_lost_result_and_fallback_are_bounded_without_redeeming_again(monkeypatch, login_flow):
|
|
generation, spawned = login_flow
|
|
with pytest.raises(TimeoutError):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
run = lark_cli._LARK_LOGIN_RUNS[("alice", generation)]
|
|
failed_destinations = set()
|
|
original_replace = os.replace
|
|
|
|
def replace(source, destination, *args, **kwargs):
|
|
name = Path(destination).name
|
|
if name.startswith((".login-result-", ".login-run-")):
|
|
failed_destinations.add(name.split("-")[1])
|
|
raise OSError(errno.ENOSPC, "synthetic full credential volume")
|
|
return original_replace(source, destination, *args, **kwargs)
|
|
|
|
with monkeypatch.context() as storage_failure:
|
|
storage_failure.setattr(os, "replace", replace)
|
|
spawned[0].released.set()
|
|
with pytest.raises(ValueError, match="Restart the authorization flow"):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=5)
|
|
run.thread.join(5)
|
|
assert not run.thread.is_alive()
|
|
assert failed_destinations == {"result", "run"}
|
|
with lark_cli._LARK_LOGIN_RUNS_LOCK:
|
|
lark_cli._LARK_LOGIN_RUNS.pop(("alice", generation))
|
|
|
|
# Storage recovers, but neither result nor failure receipt made it to disk.
|
|
# A new worker may wait one publication grace, then must reject the old code.
|
|
clock = [lark_cli.time.time()]
|
|
monkeypatch.setattr(lark_cli, "time", SimpleNamespace(time=lambda: clock[0], monotonic=lark_cli.time.monotonic, sleep=lark_cli.time.sleep))
|
|
with pytest.raises(TimeoutError):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
clock[0] += lark_cli._LARK_LOGIN_OWNER_ALIVE_GRACE_SECONDS + 1
|
|
with pytest.raises(ValueError, match="Restart the authorization flow"):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
assert len(spawned) == 1
|
|
digest = lark_cli._lark_login_code_digest("code-A")
|
|
assert lark_cli._lark_login_marker_path("alice", generation, digest).is_file()
|
|
assert not lark_cli._lark_login_result_path("alice", generation, digest).exists()
|
|
|
|
|
|
def test_foreign_poll_rereads_result_after_marker_disappears(monkeypatch, login_flow):
|
|
generation, spawned = login_flow
|
|
with pytest.raises(TimeoutError):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
run = lark_cli._LARK_LOGIN_RUNS[("alice", generation)]
|
|
digest = lark_cli._lark_login_code_digest("code-A")
|
|
marker = lark_cli._lark_login_marker_path("alice", generation, digest)
|
|
result = lark_cli._lark_login_result_path("alice", generation, digest)
|
|
original_is_file = Path.is_file
|
|
injected = threading.Event()
|
|
|
|
def is_file(path):
|
|
if path == marker and not injected.is_set():
|
|
# The foreign poll has already read a missing result. Let the
|
|
# real owner finish publishing before its next marker probe.
|
|
assert not result.exists()
|
|
injected.set()
|
|
spawned[0].released.set()
|
|
run.thread.join(10)
|
|
assert not run.thread.is_alive()
|
|
assert result.is_file() and not marker.exists()
|
|
return original_is_file(path)
|
|
|
|
monkeypatch.setattr(Path, "is_file", is_file)
|
|
assert lark_cli._await_lark_login_result("alice", generation, "code-A", 0) == {"ok": True}
|
|
assert injected.is_set(), "publication must straddle the foreign poll's two reads"
|
|
assert len(spawned) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("failure_step", ["reserve_marker", "marker_pid", "spawn", "thread_start"])
|
|
def test_failed_startup_reaps_cli_and_wakes_an_attached_poll(monkeypatch, tmp_path, failure_step):
|
|
monkeypatch.setattr(paths_module, "_paths", Paths(base_dir=tmp_path / "home"))
|
|
monkeypatch.setattr(lark_cli, "_require_lark_cli_path", lambda: "synthetic-lark-cli")
|
|
monkeypatch.setattr(lark_cli, "LARK_AUTH_COMPLETE_MIN_WAIT_SECONDS", 0)
|
|
with lark_cli._lark_credential_lock("alice"):
|
|
generation = lark_cli._advance_lark_flow_generation_locked("alice")
|
|
spawned, attached = [], []
|
|
injected = threading.Event()
|
|
|
|
def spawn(*_args, **_kwargs):
|
|
if failure_step == "spawn":
|
|
injected.set()
|
|
raise OSError(errno.EAGAIN, "synthetic process launch failure")
|
|
process = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(30)"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, start_new_session=os.name != "nt")
|
|
spawned.append(process)
|
|
return process
|
|
|
|
monkeypatch.setattr(lark_cli, "_spawn_lark_login_process", spawn)
|
|
original_start = threading.Thread.start
|
|
|
|
def fail_start(thread):
|
|
if thread.name.startswith("lark-login-"):
|
|
attached.append(lark_cli._LARK_LOGIN_RUNS[("alice", generation)])
|
|
injected.set()
|
|
raise RuntimeError("can't start new thread")
|
|
return original_start(thread)
|
|
|
|
if failure_step in {"reserve_marker", "marker_pid"}:
|
|
original_write = lark_cli._write_json_file_atomic
|
|
|
|
def fail_marker(path, payload):
|
|
should_fail = failure_step == "reserve_marker" or payload.get("cli_pid") is not None
|
|
if path.name.startswith(".login-run-") and "outcome" not in payload and should_fail:
|
|
injected.set()
|
|
raise OSError(errno.ENOSPC, "synthetic marker write failure")
|
|
return original_write(path, payload)
|
|
|
|
monkeypatch.setattr(lark_cli, "_write_json_file_atomic", fail_marker)
|
|
error = OSError
|
|
elif failure_step == "thread_start":
|
|
monkeypatch.setattr(threading.Thread, "start", fail_start)
|
|
error = RuntimeError
|
|
else:
|
|
error = OSError
|
|
try:
|
|
with pytest.raises(error):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
assert injected.is_set()
|
|
assert ("alice", generation) not in lark_cli._LARK_LOGIN_RUNS
|
|
assert all(process.poll() is not None for process in spawned), "startup failure leaked a real CLI"
|
|
for run in attached:
|
|
assert run.done.is_set(), "a concurrent waiter would remain blocked"
|
|
assert isinstance(run.outcome, BaseException)
|
|
with pytest.raises(ValueError):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
assert len(spawned) <= 1, "a failed startup must leave a terminal receipt"
|
|
finally:
|
|
with lark_cli._LARK_LOGIN_RUNS_LOCK:
|
|
lark_cli._LARK_LOGIN_RUNS.pop(("alice", generation), None)
|
|
for process in spawned:
|
|
if process.poll() is None:
|
|
process.kill()
|
|
process.communicate(timeout=5)
|
|
|
|
|
|
@pytest.mark.parametrize("cleanup", ["temporary_result", "marker"])
|
|
def test_cleanup_failure_preserves_a_published_result(monkeypatch, login_flow, cleanup):
|
|
generation, spawned = login_flow
|
|
with pytest.raises(TimeoutError):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
original_unlink = Path.unlink
|
|
injected = threading.Event()
|
|
|
|
def unlink(path, *args, **kwargs):
|
|
match = path.name.startswith("..login-result-") if cleanup == "temporary_result" else path.name.startswith(".login-run-")
|
|
if match and threading.current_thread().name.startswith("lark-login-"):
|
|
injected.set()
|
|
raise PermissionError(errno.EACCES, "synthetic cleanup failure")
|
|
return original_unlink(path, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(Path, "unlink", unlink)
|
|
spawned[0].released.set()
|
|
first = lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=5)
|
|
assert injected.is_set()
|
|
with lark_cli._LARK_LOGIN_RUNS_LOCK:
|
|
run = lark_cli._LARK_LOGIN_RUNS.pop(("alice", generation))
|
|
run.thread.join(5)
|
|
assert run.outcome == {}
|
|
second = lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
assert second.success == first.success
|
|
assert len(spawned) == 1
|
|
|
|
|
|
@pytest.mark.parametrize("publication_fails", [False, True])
|
|
def test_supersession_receipt_survives_recovered_publication(monkeypatch, login_flow, publication_fails):
|
|
generation, spawned = login_flow
|
|
with pytest.raises(TimeoutError):
|
|
lark_cli.complete_lark_auth("alice", None, generation=generation, device_code="code-A", wait_timeout_seconds=0)
|
|
run = lark_cli._LARK_LOGIN_RUNS[("alice", generation)]
|
|
injected = threading.Event()
|
|
|
|
def fail_once(original):
|
|
def publish(source, destination, *args, **kwargs):
|
|
if publication_fails and Path(destination).name.startswith(".login-result-") and not injected.is_set():
|
|
injected.set()
|
|
raise OSError(errno.EIO, "synthetic one-shot publication failure")
|
|
return original(source, destination, *args, **kwargs)
|
|
|
|
return publish
|
|
|
|
monkeypatch.setattr(os, "link", fail_once(os.link))
|
|
monkeypatch.setattr(os, "replace", fail_once(os.replace))
|
|
monkeypatch.setattr(lark_cli, "_run_lark_cli_json", lambda *_args, **_kwargs: {"verification_url": "https://example.com/verify", "device_code": "code-B"})
|
|
lark_cli.start_lark_auth("alice", generation=generation)
|
|
run.thread.join(5)
|
|
assert not run.thread.is_alive()
|
|
assert injected.is_set() is publication_fails
|
|
assert isinstance(run.outcome, lark_cli.LarkFlowSupersededError)
|
|
digest = lark_cli._lark_login_code_digest("code-A")
|
|
payload = lark_cli._read_json_object_file(lark_cli._lark_login_result_path("alice", generation, digest))
|
|
assert payload["kind"] == "superseded", "a recovered owner must adopt the fallback receipt"
|
|
assert spawned[0].killed
|