Conversation
… glob lists first
bump_favicon() names the saved file after the icon's type (favicon.png, favicon.ico, ...)
and did not remove a previous save under a different extension, so a watch whose icon
changed type ends up holding more than one favicon.*.
favicon_is_expired() then picked next(iter(glob.glob("favicon.*"))). glob order is
os.scandir order - stable, but decided by filename hash - so for some extension pairs the
stale file wins every single time, its age always exceeds the 24h threshold, and the
favicon is refetched on every check forever. Measured across realistic pairs, 3 of 10
lose that way:
old=.png new=.ico -> glob lists favicon.png first -> always expired
old=.ico new=.svg -> glob lists favicon.ico first -> always expired
old=.png new=.webp -> glob lists favicon.png first -> always expired
That is an in-page network fetch through the watch's proxy on every check, plus the
favicon fetcher's own sequential per-icon timeouts, for a favicon that is already saved
and current.
Fixed at both ends: pick the newest candidate by mtime, and have bump_favicon() drop
superseded files so the ambiguity stops arising.
Also tidied two things in the same function:
- os.path.isfile(None) raised a TypeError when the module-level filename cache said yes
after the file had been removed, which the broad except turned into a logger.critical()
for an entirely benign condition. The empty case is now handled explicitly.
- the broad except stays (this runs inline on the check path as an argument to
fetcher.run(), so it must never fail a watch) but logs at warning, since the only cost
of getting this wrong is one extra favicon fetch.
Tests fail on master (favicon.ico left behind; fresh icon reported expired) and pass here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
bump_favicon()names the saved file after the icon's type (favicon.png,favicon.ico, …) and did not remove a previous save under a different extension. So a watch whose icon changed type holds more than onefavicon.*.favicon_is_expired()then pickednext(iter(glob.glob("favicon.*"))).globorder isos.scandirorder — stable, but decided by filename hash — so for some extension pairs the stale file wins every single time, its age always exceeds the 24hFAVICON_RESAVE_THRESHOLD_SECONDS, and the favicon is refetched on every check, forever, silently.Measured across realistic pairs, 3 of 10 lose:
The cost of a false "expired" isn't nothing: it's an in-page
fetch()through the watch's proxy on every check, plusfavicon-fetcher.js's sequential per-icon 2s timeouts, for an icon that is already saved and current.The fix
Both ends of the ambiguity:
favicon_is_expired()picks the newest candidate by mtime instead of the first glob match.bump_favicon()removes supersededfavicon.*files, so the situation stops arising.Also tidied, same function
os.path.isfile(None)raisedTypeErrorwhenever the module-level filename cache said yes after the file had been removed — which the broadexceptturned into alogger.critical()for an entirely benign condition. The empty case is now handled explicitly.exceptstays deliberately (this runs inline on the check path, as an argument tofetcher.run(), so it must never be the reason a watch fails) but logs atwarning, since the only cost of being wrong here is one extra favicon fetch.Tests
Four tests added to
TestFaviconExpiry. Two of them fail on master:Both pass with the change.
🤖 Generated with Claude Code