feat(login): implement faster stuck-loading detection with _loading_buffer_visible

- Added _loading_buffer_visible function to detect the loading spinner badge
- Updated _wait_for_home to utilize the new detection for early recovery
- Introduced configuration options for loading buffer detection parameters
- Improved handling of stuck loading state to reduce wait time

fix(cron): make ba_cron_run.sh executable

- Changed file mode of ba_cron_run.sh to 755 to ensure it is executable
- Verified that cron can now execute the script as intended

fix(navigation): prevent crash in return_to_home when window disappears

- Added guard to return_to_home to check if the game window exists before focusing
- Ensured robustness in cleanup path during game exit
This commit is contained in:
Nik Afiq 2026-07-20 01:21:53 +09:00
parent b8ac8db01e
commit a3cea4d192
7 changed files with 141 additions and 1 deletions

View File

@ -1307,3 +1307,33 @@ LOGIN_TIMEOUT_SECONDS = 240
LOGIN_MAX_RELAUNCHES = 2 LOGIN_MAX_RELAUNCHES = 2
LOGIN_KILL_WAIT_ATTEMPTS = 10 # ~20s for the old process to fully exit LOGIN_KILL_WAIT_ATTEMPTS = 10 # ~20s for the old process to fully exit
LOGIN_RELAUNCH_WAIT_ATTEMPTS = 45 # ~90s+ for the window to reappear LOGIN_RELAUNCH_WAIT_ATTEMPTS = 45 # ~90s+ for the window to reappear
# The full-bleed loading transition (rotating splash art, no chrome at all --
# see login.py's module docstring, state 3's "second variant") renders a
# small gray spinner badge dead center on screen regardless of which splash
# art frame is showing behind it. Live-captured 2026-07-19 during a real
# stuck instance (the user ran `ba_cron_run.sh daily` manually and reported
# the game stuck at login -- screenshots/daily_login/stuck_loading_buffer.png,
# calibrated via scratchpad/probe_login_buffer.py): the badge's left/right edges read a
# flat, exact neutral gray (118,118,118) -- R==G==B, unlike any of the
# colorful splash-art pixels sampled elsewhere on the same screenshot
# (all clearly non-gray, e.g. (216,233,207), (107,103,158)) -- across the
# whole sampled y-range at x=928 and x=992 (the badge spans roughly
# x=[928,992], y=[571,627], centered on the screen's own true center,
# (960,600)). Deliberately samples only these flat edge columns, not the
# badge's own interior (which has a white spinner icon washing out some
# interior pixels to near-white) -- same "avoid the part that visibly
# varies" reasoning as every other multi-point probe in this project.
LOGIN_LOADING_BUFFER_PROBES = ((928, 580), (928, 600), (928, 620), (992, 580), (992, 600), (992, 620))
LOGIN_LOADING_BUFFER_RGB = (118, 118, 118)
LOGIN_LOADING_BUFFER_TOLERANCE = 12
# How long this exact badge must be seen continuously before treating it as
# stuck rather than a normal (if slow) loading transition -- the "brief"
# loading variant in login.py's docstring resolved in a few seconds during
# calibration, and there's no real data on how long the full-bleed variant
# normally takes when it ISN'T stuck, so this stays well above that to avoid
# killing a genuinely-progressing load. Far shorter than the generic
# LOGIN_TIMEOUT_SECONDS=240 blind wall-clock budget, though, since this is a
# specific, well-understood bad state (not "anything unrecognized") -- no
# reason to wait the full 4 minutes once it's confidently identified.
LOGIN_LOADING_BUFFER_STUCK_SECONDS = 60

View File

@ -130,6 +130,28 @@ def return_to_home(driver):
-- correct for them, since they have no ability to start the game -- -- correct for them, since they have no ability to start the game --
this only changes behavior for the specific "no window yet" case this this only changes behavior for the specific "no window yet" case this
function itself can't do anything about anyway. function itself can't do anything about anyway.
The escalation call itself now has the same window_exists() guard,
added 2026-07-20 after a real crash: the entry check above only covers
the window being gone at the START of this function, not it
disappearing mid-loop. Confirmed live via a real q4h cron run's
traceback -- exit_game closed the game and printed its own "closed"
confirmation, then ba_daily.py's post-task cleanup called this function
immediately afterward with zero delay; driver.window_exists() still
read True at that exact instant (the game's own teardown apparently
isn't instantaneous -- the window can take a moment to fully
disappear from xdotool's search after the in-game exit is confirmed),
so the entry guard passed and the retry loop began pressing
Escape/BACK_BUTTON against a game that was already exiting. By the
time the loop reached its halfway escalation point a few seconds
later, the window had fully disappeared for real, and the unguarded
driver.focus_game() call crashed the whole script with an uncaught
RuntimeError. Fixed by checking window_exists() again right at the
escalation point too (matching click_back's own already-established
convention below), returning False instead of crashing if the window
is gone by then -- this is a general robustness fix benefiting any
task's cleanup path where the window can disappear mid-retry, not just
exit_game's.
""" """
if not driver.window_exists(): if not driver.window_exists():
return False return False
@ -148,6 +170,8 @@ def return_to_home(driver):
if not escalated and round_num >= RETURN_HOME_MAX_ROUNDS // 2: if not escalated and round_num >= RETURN_HOME_MAX_ROUNDS // 2:
escalated = True escalated = True
if not driver.window_exists():
return False
print("[navigation] still not home halfway through recovery -- re-raising the game window in case a stray overlay (e.g. XIGNCODE) is intercepting input") print("[navigation] still not home halfway through recovery -- re-raising the game window in case a stray overlay (e.g. XIGNCODE) is intercepting input")
driver.focus_game() driver.focus_game()
return not _not_home(driver) return not _not_home(driver)

File diff suppressed because one or more lines are too long

View File

@ -212,6 +212,38 @@ title/loading/attendance-card states this module handles. `_wait_for_home`
checks true-home FIRST on every iteration (same contract as checks true-home FIRST on every iteration (same contract as
`navigation.wait_for_state`), so a session that's already logged in and `navigation.wait_for_state`), so a session that's already logged in and
sitting at home is a fast no-op, not a risky blind click. sitting at home is a fast no-op, not a risky blind click.
**Faster stuck-loading detection, `_loading_buffer_visible` (2026-07-19)**:
the user manually ran `ba_cron_run.sh daily` and reported the game stuck at
login; by the time it was checked, the run was still well within its own
first `LOGIN_TIMEOUT_SECONDS` (240s) budget -- not actually broken, just
early in a bounded wait that hadn't reached its own timeout/recover point
yet (confirmed: `_recover`'s existing kill+relaunch DID fire once that
budget elapsed, per the user's own follow-up "Oh the game did relaunched").
But blindly waiting out the full generic timeout for a state this
recognizable is slower than it needs to be. Live-captured the real stuck
screen (`screenshots/daily_login/stuck_loading_buffer.png`, pulled via `scrot` over ssh) --
exactly state 3's full-bleed loading variant from this docstring's own
account above, rotating splash art with a small gray spinner badge dead
center. Pixel-probed it (`scratchpad/probe_login_buffer.py`): the badge's
left/right edges read a flat, exact neutral gray `(118,118,118)` --
R==G==B, unlike any sampled splash-art pixel elsewhere on the same
screenshot -- consistently across its whole vertical span, centered on the
screen's own true center. Since this is a fixed UI overlay independent of
whichever splash-art frame happens to be showing behind it, it doesn't
share the "different frame fools a different single-snapshot check" failure
class that already bit `_true_home`/`_connectivity_confirmed` twice (see
above). `_wait_for_home` now tracks how long this specific badge has been
seen continuously and bails out to `_recover` after
`LOGIN_LOADING_BUFFER_STUCK_SECONDS` (60s, well above the "brief" loading
variant's few-second normal case, far below the generic 240s budget) rather
than waiting out the full generic timeout once this exact, well-understood
bad state is confidently recognized. Verified offline against the real
capture (all 6 probe points read within tolerance of the target). Not yet
live-confirmed against a fresh real stuck instance recovering via this
faster path specifically (the instance that prompted this fix had already
recovered via the pre-existing generic-timeout path by the time the fix was
written).
""" """
import time import time
@ -236,6 +268,19 @@ def _news_dialog_open(driver, config):
return all(_color_matches(c, config.LOGIN_NEWS_HEADER_RGB, config.LOGIN_NEWS_HEADER_TOLERANCE) for c in colors) return all(_color_matches(c, config.LOGIN_NEWS_HEADER_RGB, config.LOGIN_NEWS_HEADER_TOLERANCE) for c in colors)
def _loading_buffer_visible(driver, config):
"""Positive detector for the full-bleed loading transition's center
spinner badge (see config.py's LOGIN_LOADING_BUFFER_* comment for the
live pixel calibration) -- confirms we're in that specific known-can-
get-stuck state rather than one of the many other unrecognized dialogs
the generic Enter-fallback handles, so _wait_for_home can bail out to
_recover on a much shorter, purpose-specific timeout instead of the
full generic wall-clock budget.
"""
colors = driver.colors_at(config.LOGIN_LOADING_BUFFER_PROBES)
return all(_color_matches(c, config.LOGIN_LOADING_BUFFER_RGB, config.LOGIN_LOADING_BUFFER_TOLERANCE) for c in colors)
def _home_nav_bar_visible(driver, config): def _home_nav_bar_visible(driver, config):
for r, g, b in driver.colors_at(config.LOGIN_HOME_NAV_BAR_PROBES): for r, g, b in driver.colors_at(config.LOGIN_HOME_NAV_BAR_PROBES):
if min(r, g, b) < config.LOGIN_HOME_NAV_BAR_MIN_CHANNEL: if min(r, g, b) < config.LOGIN_HOME_NAV_BAR_MIN_CHANNEL:
@ -306,7 +351,16 @@ def _connectivity_confirmed(driver, config):
def _wait_for_home(driver, config): def _wait_for_home(driver, config):
start = time.time() start = time.time()
buffer_since = None
while time.time() - start < config.LOGIN_TIMEOUT_SECONDS: while time.time() - start < config.LOGIN_TIMEOUT_SECONDS:
if _loading_buffer_visible(driver, config):
if buffer_since is None:
buffer_since = time.time()
elif time.time() - buffer_since >= config.LOGIN_LOADING_BUFFER_STUCK_SECONDS:
print(f"[login] loading buffer stuck for {config.LOGIN_LOADING_BUFFER_STUCK_SECONDS}s+ -- not waiting out the full timeout")
return False
else:
buffer_since = None
if _true_home(driver, config): if _true_home(driver, config):
if not _connectivity_confirmed(driver, config): if not _connectivity_confirmed(driver, config):
print("[login] looked like home but the mission panel wouldn't open -- possible transition frame or dead session, retrying") print("[login] looked like home but the mission panel wouldn't open -- possible transition frame or dead session, retrying")

0
ba_cron_run.sh Normal file → Executable file
View File

32
plan.md
View File

@ -941,6 +941,38 @@ Live steps taken on nik-gpu (not just docs):
**Verification**: a standalone `~/repo/ba-auto-daily/ba_dailies.sh --list-commands` / task dispatch through the new path, plus confirming the crontab's next fire actually reaches `exit_game`, are the two things worth checking to fully close this out -- see plan.md's "Immediate next steps"-style follow-up once the next scheduled `q4h`/`daily` fire happens. **Verification**: a standalone `~/repo/ba-auto-daily/ba_dailies.sh --list-commands` / task dispatch through the new path, plus confirming the crontab's next fire actually reaches `exit_game`, are the two things worth checking to fully close this out -- see plan.md's "Immediate next steps"-style follow-up once the next scheduled `q4h`/`daily` fire happens.
### Phase 20 follow-up: `ba_cron_run.sh` was never actually executable in git (2026-07-19)
The morning after Phase 20 landed, the user pulled logs and reported nothing had run since the fix -- worth checking, since by then 5 scheduled fires had already passed (q4h@01:00, daily@3:30, daily@4:30, q4h@5:00, q4h@9:00) with zero new log lines, not just "too early to tell."
Root cause: `git ls-files --stage ba_cron_run.sh` showed mode `100644` (not executable) -- unlike `ba_dailies.sh`/`setup.sh`, both correctly `100755`. This had been true in the repo the whole time; it was invisible under the old deploy model because cron called the manually-deployed `~/ba_cron_run.sh` copy, which had its own executable bit set independently (by hand, at some point, outside git and outside `setup.sh` -- the original `setup.sh` never even copied this file, see its pre-Phase-20 content). Phase 20 pointed cron directly at the checkout's own file for the first time, and its real, always-644, never-noticed git-tracked mode finally mattered -- cron silently couldn't execute it at all, for every fire since the migration.
Fixed with `chmod +x ba_cron_run.sh` (both locally, so the correct mode is preserved going forward via git's own tracked file mode -- as of this writing not yet committed, since commits in this project only happen on explicit request -- and immediately on nik-gpu's live checkout, so cron didn't have to wait for another rsync). Verified via the same safe bogus-preset dry run this project has used before (`~/repo/ba-auto-daily/ba_cron_run.sh bogus_preset_test`): correctly executed, acquired the lock, dispatched to `ba_dailies.sh`, got the expected "Unknown phase" rejection, and logged `FAILED (exit 1)` -- no game interaction, confirms the wrapper itself now runs.
Not yet confirmed: an actual real scheduled fire succeeding end-to-end post-fix (next one due at q4h's 13:00 JST slot). The user does not need to manually run `ba_cron_run.sh` themselves -- cron will pick it up automatically now that the file is executable; the open item is just watching that next natural fire's log.
### Phase 20 follow-up #2: faster stuck-login-loading detection, `_loading_buffer_visible` (2026-07-19)
The user manually ran `./ba_cron_run.sh daily` (to sanity-check the Phase 20 fix without waiting for the next cron fire) and reported the game stuck at login. Checked timing first: the run had only been going ~3.5 minutes, still within `login.py`'s own first `LOGIN_TIMEOUT_SECONDS` (240s) per-attempt budget -- not actually broken, just legitimately early in a bounded wait. Confirmed correct shortly after: the user reported "Oh the game did relaunched" once the 240s mark passed, meaning the pre-existing `_recover` kill+relaunch fired exactly as designed.
The user still wanted this faster -- specifically asking to detect the stuck state via "the buffer at center" and trigger kill+relaunch on it, rather than only via the blind wall-clock timeout. Live-captured the actual stuck screen via `scrot` over ssh into `scratchpad/` first (this repo's scratchpad, not `/tmp`), then kept as `screenshots/daily_login/stuck_loading_buffer.png` since it directly calibrated a permanent detector -- while the real stuck instance was still up, confirmed it's exactly state 3's full-bleed loading variant from `login.py`'s own module docstring (rotating splash art, no chrome), with a small gray spinner badge dead center. Pixel-probed it (`scratchpad/probe_login_buffer.py`): the badge's left/right edge columns read a flat, exact neutral gray `(118,118,118)` -- R==G==B -- across their whole sampled vertical span, clearly distinct from every sampled splash-art pixel elsewhere on the same screenshot (all clearly non-gray). The badge's bounding box (roughly x=[928,992], y=[571,627]) is centered almost exactly on the screen's true center (960,600), matching the user's own description.
Added `config.LOGIN_LOADING_BUFFER_PROBES`/`_RGB`/`_TOLERANCE`/`_STUCK_SECONDS` and `login._loading_buffer_visible`. `_wait_for_home` now tracks how long this specific badge has been seen continuously (`buffer_since`) and returns False early -- triggering `run()`'s existing `_recover` call, no new call site needed -- once it's been visible for `LOGIN_LOADING_BUFFER_STUCK_SECONDS` (60s), instead of only bailing out after the full generic 240s. 60s was chosen as a deliberate middle ground: well above the "brief" loading variant's few-second normal case (no real data exists on how long the full-bleed variant normally takes when it ISN'T stuck, so this stays conservative), but far below the generic 240s budget, since this is now a specific, well-understood bad state rather than "anything unrecognized."
Why this one doesn't share the earlier false-positive problem: `_true_home`/`_connectivity_confirmed` got fooled twice (see Phase 18 follow-up #3) because different ROTATING SPLASH FRAMES could coincidentally satisfy a single-snapshot visual check. This badge is a fixed UI overlay independent of whatever art is rotating behind it, so it doesn't inherit that failure mode.
Verified offline: all 6 probe points read within tolerance against the real captured screenshot. **Not yet live-confirmed** against a fresh stuck instance actually recovering via this specific faster path -- the triggering instance had already self-recovered via the pre-existing generic-timeout path by the time this fix was written and deployed.
### Phase 20 follow-up #3: `navigation.return_to_home` crash when the window disappears mid-loop (2026-07-20)
A real unattended `q4h` cron fire crashed outright (`FAILED (exit 1)`, uncaught `RuntimeError`), reported live by the user with the full traceback and pulled log. Sequence from the log: `event_sweep` swept successfully and finished, `exit_game` ran and printed its own `"[exit_game] game closed."` (meaning its `_confirm_exit` loop had itself already observed `driver.window_exists()` read False), then immediately -- `ba_daily.py`'s centralized post-task cleanup (`_run_task`'s `finally` block, see Phase 19) called `navigation.return_to_home(driver)`, which crashed with `RuntimeError: Blue Archive window not found` from inside `driver.focus_game()`.
Root-caused by re-reading `return_to_home`'s own code: it already guards its OWN entry (`if not driver.window_exists(): return False`, added for Phase 18's login cold-start case) but the escalation call deeper in its retry loop (`driver.focus_game()`, fired halfway through the round budget as a XIGNCODE-overlay defense -- see the function's own docstring) had no equivalent guard. The real sequence: `_run_task`'s `finally` block's own `driver.window_exists()` check (added in Phase 19 specifically so this check gets skipped once exit_game legitimately closes the game) still read **True** at that exact instant -- the game's teardown after the in-game exit confirmation apparently isn't instantaneous, and xdotool's window query caught it mid-teardown, a few hundred ms before the window was actually, fully gone. That let `return_to_home`'s own entry guard pass too, so it entered the retry loop and started pressing Escape/BACK_BUTTON against a game that was already in the process of exiting. By the time the loop reached its halfway escalation point a few seconds later, the window really had fully disappeared, and the unguarded `driver.focus_game()` call crashed with an uncaught `RuntimeError`, killing the whole script.
Confirmed via direct inspection on nik-gpu right after the crash that nothing was actually left in a bad state: no `BlueArchive` window, no `BlueArchive.exe` process, and the cron lock file held by nothing -- the game really had closed cleanly as `exit_game` intended; the ONLY problem was the crash itself in the cleanup path immediately afterward, not any lingering bad game state.
Fixed with the same one-line defensive pattern `click_back` already uses for its own `focus_game()` call: check `driver.window_exists()` again right at the escalation point, returning `False` instead of crashing if the window is already gone by then. This is a general robustness fix to shared navigation cleanup, not something specific to `exit_game` -- any task whose cleanup runs while the game window is disappearing (not just a deliberate `exit_game` close) could in principle hit the same race. Deployed; not yet re-confirmed against a fresh real `exit_game`-then-cleanup sequence (would need another real `q4h`/`daily` fire that includes `exit_game`).
## Prerequisites ## Prerequisites
### OCR ### OCR

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB