Skip to content

fix(cache): treat a non-UTF-8 cache file as a miss, not a crash - #161

Open
MohammedAlkindi wants to merge 1 commit into
Andyyyy64:mainfrom
MohammedAlkindi:fix/cache-unicode-decode-error
Open

fix(cache): treat a non-UTF-8 cache file as a miss, not a crash#161
MohammedAlkindi wants to merge 1 commit into
Andyyyy64:mainfrom
MohammedAlkindi:fix/cache-unicode-decode-error

Conversation

@MohammedAlkindi

Copy link
Copy Markdown

Closes #160

Symptom

A cache file that is not valid UTF-8 exits whichllm with a traceback instead of refreshing the cache:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 81: invalid continuation byte

Both loaders promise the opposite. load_cache() is documented as "Returns None if expired or missing", and both already carry a handler that logs Cache corrupted and returns None.

Cause

cache.py and benchmark_cache.py read with read_text(encoding="utf-8") and then catch:

except (json.JSONDecodeError, KeyError) as e:

UnicodeDecodeError is a ValueError, but it is not a json.JSONDecodeError, so it is not caught. cli.py calls both loaders bare (lines 623, 782, 868), so it propagates to the user.

This is an upgrade path, not a hypothetical. Before 07140d5 the cache was written with ensure_ascii=False through the locale codepage, so on Windows a cached model id containing non-ASCII is on disk as cp1252 bytes today. Upgrading to ≥0.5.12 fixes the writer but leaves that file, and the first run afterwards reads it back. A truncated write reaches the same place with no version history involved.

Change

UnicodeDecodeError added to both handlers — two lines. An undecodable cache now degrades to a miss like every other corruption.

Testing

Windows 11, Python 3.13, locale.getpreferredencoding(False) == 'cp1252'.

Two regression tests in the existing tests/test_cache_encoding.py. They write a real cp1252 cache file, because the fakes already in that file return str and so cannot express a decode error. Reverting only the two source lines:

FAILED tests/test_cache_encoding.py::test_model_cache_survives_non_utf8_file
FAILED tests/test_cache_encoding.py::test_benchmark_cache_survives_non_utf8_file
E   UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 53: invalid continuation byte
2 failed, 3 passed in 0.24s

With the change:

tests/test_cache_encoding.py .....            5 passed in 7.10s
ruff check                                    All checks passed!
ruff format --check                           3 files already formatted

Full suite, same machine:

before:  24 failed, 460 passed
after:   24 failed, 462 passed

The 24 are pre-existing on a clean ea32ed2 checkout and live in test_gpu_simulator.py and test_nvidia_detection.py, which this PR does not touch. I diffed the two FAILED lists and they are identical, so no new failure is introduced — but I have not investigated why those 24 fail here, and CI is the authority on that.

Deliberately unchanged

  • The writers. 07140d5 already pinned encoding="utf-8" on both, so nothing new lands undecodable; this only stops the reader dying on files written before that.
  • _ReadableCacheFile / _WritableCacheFile. They are right for asserting which encoding a caller passes, and the new tests need real bytes instead, so both styles now sit side by side rather than one replacing the other.
  • The except clauses stay narrow. Adding OSError or a bare except would also swallow a genuinely unreadable cache directory, which is a different failure and should still surface.

Limitations

  • The 24 pre-existing failures above are unexplained by me.
  • CI here is ubuntu-latest only, and the reproduction is Windows-specific because it depends on a cp1252 default encoding. The regression tests themselves are platform-independent: they write cp1252 bytes explicitly rather than relying on the ambient locale, so they exercise the same path on Linux CI.
  • I have not reproduced a user hitting this in the wild; the upgrade path is derived from the pre-07140d5 writer, not from a report.

load_cache() and load_benchmark_cache() promise a cache miss on a bad
file: the docstring says "Returns None if expired or missing" and the
handler logs "Cache corrupted" and returns None. They catch
(json.JSONDecodeError, KeyError), but read_text(encoding="utf-8")
raises UnicodeDecodeError, which is a ValueError and not a
JSONDecodeError, so it escapes and reaches the CLI uncaught.

cli.py calls both loaders bare, e.g.

    cached_data = None if refresh else load_cache()

so the user gets a traceback instead of a silent cache refresh.

This is reachable on the upgrade path rather than in theory. Before
07140d5 the cache was written with ensure_ascii=False through the
locale codepage, so on a Windows machine a cached model id containing
non-ASCII is sitting on disk right now as cp1252 bytes. Reading that
file with the current code raises

    UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in
    position 81: invalid continuation byte

A truncated write from a killed process produces the same class of
failure without any version history.

Adding UnicodeDecodeError to both handlers restores the documented
behaviour. Regression tests write a real cp1252 cache file, since the
existing fakes return str and cannot express this.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Non-UTF-8 cache file raises an uncaught UnicodeDecodeError instead of a cache miss

1 participant