Skip to content

[Bug]: Windows non-UTF-8 locale — Path text I/O decodes structured state files with the locale encoding #4941

Description

@GZY-SUPER-HACKER

Summary

On a Windows host whose ANSI code page is not UTF-8 (a zh-CN host with cp936/GBK),
LoopX code paths that read structured state files through Path.read_text() without an
explicit encoding
decode them with locale.getpreferredencoding(False), which is gbk.
JSON (RFC 8259) and TOML are UTF-8 by specification, so any file carrying a non-ASCII
character is either rejected or silently mis-decoded.

  • bytes GBK cannot decode -> UnicodeDecodeError, which is a ValueError subclass, so
    callers that catch (OSError, ValueError) swallow it and report a misleading secondary error;
  • bytes GBK can decode -> silent wrong data: 用户任务 reads back as 鐢ㄦ埛浠诲姟.

Same class as #4155, different code path. #4396 pinned UTF-8 for every remaining text-mode
subprocess read
and closed #4155; the Path.read_text() / Path.write_text() path was not
covered by that sweep.

Expected behavior

Text reads and writes of structured state files (.json / .toml) decode and encode as
UTF-8 regardless of the host's ANSI code page. Concretely:

  • loopx goal-portfolio --manager-view portfolio --format json succeeds on a cp936 host
    whose registry.json carries a non-ASCII knowledge_root / state_file, instead of
    returning {"ok": false, "error": "manager_evidence_unavailable_or_invalid", "rows": []};
  • a record written by collaboration/inbox.py can be read back by the same module on the
    host that wrote it.

Reproduction — real command, real output

A project registered under a non-ASCII path is sufficient: registry.json carries filesystem
paths (project_record.knowledge_root, goal_record.state_file), and non-ASCII directory
names are ordinary for a zh-CN user.

loopx goal-portfolio --manager-view portfolio --format json
{ "ok": false, "error": "manager_evidence_unavailable_or_invalid", "rows": [] }

Exit status 1. Nothing in the output points at encoding.

The only variable is the encoding default. Same fixture, same command, same machine:

Invocation Result
loopx goal-portfolio ... {"ok": false, "error": "manager_evidence_unavailable_or_invalid", "rows": []}
PYTHONUTF8=1 loopx goal-portfolio ... {"ok": true, "view": "portfolio", ...}

Root cause: loopx/capabilities/manager_context/evidence_export.py:12 reads the registry
without an explicit encoding.

Scope — 10 sites in loopx/

Measured with an AST scan over the package (a grep count is misleading here, because the
tree uses multi-line calls):

Kind Count
Path.read_text() without encoding 9
Path.write_text() without encoding 1

Most consequential:

  • loopx/control_plane/collaboration/inbox.py:56 — the module's own _write uses
    encoding="utf-8" and ensure_ascii=False, so a record written by LoopX cannot be read
    back by LoopX on a non-UTF-8 host.
  • loopx/capabilities/connector_registry/core.py:149 (write side) — save_connector_registry
    writes with the locale encoding while load_connector_registry reads as UTF-8. On cp936
    the write produces GBK bytes, the read raises, except (OSError, ValueError) swallows it,
    and the loader silently falls back to built-in defaults.
  • loopx/codex_cli_runtime_probe.py L172/183/190/197 — the same file where fix(runtime): pin UTF-8 decoding for every remaining text-mode subprocess read #4396 pinned
    L222/275 (subprocess) but left these four (file read) untouched.

Planned change

Add an explicit encoding="utf-8" to those 10 product-code call sites — one keyword per site,
no control-flow change. I am not proposing to touch the ~449 matching call sites under
tests/, examples/ and benchmark/ in the same change; they are a separate slice and I am
happy to take them separately if you would rather.

Separate decision, not in this change

CPython ships EncodingWarning for exactly this pattern (PEP 597). Run pytest with
-X warn_default_encoding plus filterwarnings = error::EncodingWarning and the class is
caught rather than the instances. Measured on this host:

  • against the unfixed call sites under benchmark/tests/, it fails the same 5 tests that
    PYTHONUTF8=1 fixes — so it does detect exactly this defect;
  • against two test files whose modules are already fixed (17 passed + 18 passed before),
    it reports 18 failed / 7 passed / 10 errors — the currently-passing tests go red, because
    the ~449 unfixed sites under tests/, examples/ and benchmark/ are inside the same run.

So enabling the gate is a CI-wide decision that first requires that cleanup to land. Flagging
it here, not proposing it. (ruff's PLW1514 covers part of it — 6 of these 10 — but it is
preview-only today.)

If you would rather have the ~449 sites fixed first and the gate enabled in one go, I am happy
to take that as a follow-up slice instead of this one.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions