Push dropped the correction JSONs — Windows MAX_PATH
Summary
*_vectors.adjusted.json correction files. On the shared
remote, 260611_Abschnitt_3 had its sidecar but 0 of its 56 correction
files. The cause is not in the sync logic — it is Windows'
260-character path limit (MAX_PATH). The atomic file writer finishes with
os.replace(tmp, final), which fails with WinError 3 for any final
path ≥ 260 chars when LongPathsEnabled = 0 (the machine's default). The sidecar path
is short enough to land; the deeper correction paths are not — so only the corrections vanished.
The fix
| Decision | Choice | Why |
|---|---|---|
| How to beat MAX_PATH | Prefix every filesystem path with the extended-length form \\?\ on Windows |
Bypasses the 260-char limit (up to ~32,767) without needing admin rights or a registry change on every colleague's PC |
| Where to apply it | One helper store.long_path(), routed through load_json,
atomic_write_json, atomic_write_bytes, cleanup_tmp,
vectors.file_sha1 |
Single choke-point; every read/write/rename that could hit a deep tiles/ path is covered |
| Behaviour off Windows | No-op (returns the path unchanged) | Linux/macOS have no MAX_PATH; the change is invisible there |
| Guard against regression | First unit tests in the repo (pytest) | The push code had zero tests; now the sidecar-vs-correction contract is pinned |
Walkthrough
1 · The symptom reported
A colleague pushed to the shared gDrive (N:) folder. The sidecars showed up, the
marks were there — but a folder like Abschnitt 3 had none of its correction files.
From the app it looked like "push works for sidecars but not the small JSON files."
Crucially, the push summary said "skipped N" — the writes were failing, but the failure was swallowed and shown only as a bland "skipped" count with no reason.
2 · Root cause MAX_PATH
Both the sidecar and the correction files are written by the same atomic writer in
store.py. It writes to a short temp file, then atomically renames it into place:
fd, tmp = tempfile.mkstemp(prefix="~ds", dir=path.parent)
...write + fsync...
os.replace(tmp, path) # <-- fails here if len(path) >= 260 on Windows
The old code kept the temp name short to stay under MAX_PATH — but that only protects
the temp file. The final os.replace target is the real, long name, and that is what
Windows rejects when LongPathsEnabled = 0.
Why only the corrections? Path length:
- Sidecar:
…/Abschnitt_3/dataset_review.sidecar.json≈ 228 chars → under 260 → lands. - Correction:
…/Abschnitt_3/tiles/<stem>_intensity_vectors.adjusted.json≈ 291 chars → over 260 → fails.
The correction files sit one level deeper (tiles/) and have a long, descriptive
name — just enough to cross the line.
3 · Evidence (measured on the real remote + Windows Python)
The app's own runtime is Windows Python 3.11 with LongPathsEnabled = 0. Reproduced
directly: os.replace wrote fine at 245 chars and failed with WinError 3
at ≥ 260. Then, per folder on the shared remote:
| Folder | Sidecar (chars) | Adjusted in sidecar | Files on remote | Path length |
|---|---|---|---|---|
| Abschnitt_1_short | 234 | 42 | 42 ✓ | 256 |
| Abschnitt_1_long | 233 | 74 | 74 ✓ | 254 |
| Abschnitt_4_5 | 230 | ~80 | 80 ✓ | 242 |
| Abschnitt_3 | 228 | 56 | 0 ✗ | 291 |
| Abschnitt_2 | 228 | 231 | 190 (old) | 281 |
The pattern is binary per folder: where the correction path stays under 260 (Abschnitt 1), every file lands; where it crosses 260 (Abschnitt 3 at 291), none do. The sidecar — always ~228 — lands in every case. That is exactly "sidecars sync, small JSONs don't."
4 · The code fix shipped
A single helper maps a path to Windows' extended-length form; every writer/reader goes through it.
def long_path(path):
r"""Windows: map to the \\?\ extended-length form so file ops
bypass the 260-char MAX_PATH limit. No-op off Windows."""
s = os.fspath(path)
if os.name != "nt" or s.startswith("\\\\?\\"):
return s
abspath = os.path.abspath(s)
if abspath.startswith("\\\\"): # UNC share
return "\\\\?\\UNC\\" + abspath[2:]
return "\\\\?\\" + abspath
# in the atomic writer:
os.replace(tmp, long_path(path)) # now survives long paths
Verified on the real Windows runtime: a 304-char path now round-trips (write → read → overwrite) where before it failed.
5 · Tests (first in the repo)
pytest was added to pyproject.toml; a new tests/ dir covers:
- Push behaviour — corrections must land, not just the sidecar; the "folder left empty" regression; idempotent re-push; overwrite; conflict take-local / keep-dest; tags-config travel; delete propagation.
- Long-path fix —
long_path()mapping (drive / UNC / idempotent / off-Windows no-op) and the atomic writers surviving a > 260-char path.
uv run --with pytest pytest # 20 passed
Delivered on branch AI3D-226-fix-push-longpath →
PR #1.
Risks & caveats
| Risk | Severity | Note |
|---|---|---|
\\?\ on the Google Drive (N:) mount |
medium | Verified against a local Windows disk, not the shared drive (writing there is blocked for the agent). Should be confirmed with one throwaway write to N: before wide rollout. |
| Existing over-limit files already on the remote | low | Abschnitt_3's 56 corrections were never uploaded — re-push after the fix lands them. Folders whose files already exist (from earlier good pushes) are unaffected. |
Read-side path checks (Path.exists()) still limited |
low | For a > 260-char file that already exists, re-push treats it as "new" and overwrites — harmless for push (take-local policy, idempotent content). |
Decisions & open questions
- done Surface write failures in the push summary instead of a silent count — implemented. Failed writes now pop a warning listing each file and its reason (
path too long (N chars), grouped, first 8 + "… and N more"); the status bar appendsFAILED N. Genuine failures are separated from intentional keep-dest skips. - no Enable long paths at the OS level (
LongPathsEnabled = 1via GPO) across the team — decided against. The\\?\fix lives in the app and is self-contained, so it needs no per-machine registry/GPO change. - open Confirm the
\\?\N:\…write actually succeeds on the live Google Drive mount before closing the ticket.