Repository navigation
fix(uploads,integrations): keep atomic-write cleanup from replacing the caller's outcome - #7200
Conversation
bb51b9c to
4ed9c88
Compare
Two scope questions while this is in review1. Is that path in scope for the same contract, or a deliberate exception? It is a 2. Any other site you would rather see first. Re-running the AST pass for So this contract looks finished apart from the If the answer is "this contract is done after #7200", that is a fine answer and No code is proposed in this comment, and nothing here blocks the current diff. |
willem-bd
left a comment
There was a problem hiding this comment.
Verified at head 4ed9c88: the mechanism is right at all three sites — the shared utils.atomic_write.cleanup_failed_atomic_write (already on main) keeps cleanup best-effort and log-only, the bare raise re-raises the original error, and the fdopen-outside-with ownership transfer closes the descriptor-leak gap. I reproduced the PR's red/green claim exactly (5 failed / 3 passed with the three production files reverted to eb7c342; 8 passed at head), and the new tests inject failures at real seams (os.replace/os.fdopen/Path.unlink), not handler source. Scope is tight (4 files). Two things to sort out before merge: (1) this duplicates two of open #7164's sites byte-for-byte (manager.py::_write_bytes_atomically, skills.py::_copy_uploaded_skill_archive — #7164 additionally covers the skill-storage writers, this PR additionally covers lark_cli.py), and neither PR references the other, so whichever lands second will conflict; (2) two same-class siblings are still unfixed on main at this head and appear in no open PR — most importantly uploads/companions.py, which has the full bug class this PR fixes, including the error-replacement half.
| temporary = Path(temporary_name) | ||
| try: | ||
| with os.fdopen(fd, "wb") as handle: | ||
| handle = os.fdopen(fd, "wb") |
There was a problem hiding this comment.
[coordination] This exact change to _write_bytes_atomically (fdopen outside the with, fd = None, cleanup_failed_atomic_write(temporary, fd)) is also pending, byte-for-byte, in open PR #7164 — which likewise rewrites app/gateway/routers/skills.py::_copy_uploaded_skill_archive identically to this PR. The two PRs otherwise partition the remaining sites (#7164 also takes the skill-storage writers; this one uniquely takes lark_cli.py::_publish_bytes_atomically), but neither references the other, despite this PR's body saying each site was verified "free of an open PR". Worth coordinating before merge: land one and rebase the other, or split so each owns disjoint sites — otherwise the second merge is a guaranteed conflict on both files.
| temporary = Path(temporary_name) | ||
| try: | ||
| with os.fdopen(fd, "wb") as handle: | ||
| handle = os.fdopen(fd, "wb") |
There was a problem hiding this comment.
[important] Same class, still unfixed on main at this head, in no open PR: integrations/lark_broker.py::_write_text_atomically (~line 476) still binds with os.fdopen(fd, "w", ...), so an fdopen failure there leaks the descriptor exactly as described in the PR body — and its cleanup failure is swallowed by a bare except OSError: pass with no log, so the leftover temporary is unobservable. Worse, uploads/companions.py::_write_companion_record (lines 42-55) has the full class including the error-replacement half: an unguarded finally: os.unlink(staged) after os.replace means an unlink failure there replaces the caller's error. If these fell outside the #7106 census, they are worth a follow-up PR (or extending this one) now that the ownership-transfer pattern is established right here.
| except Exception: | ||
| if destination is not None: | ||
| destination.unlink(missing_ok=True) | ||
| cleanup_failed_atomic_write(destination) |
There was a problem hiding this comment.
[suggestion] Same file, same class, a few blocks away: the upload endpoint's finally (line 585 at head) discards the copied archive with an unguarded await asyncio.to_thread(temporary_path.unlink, missing_ok=True). An unlink failure there replaces the endpoint's outcome — a successful 200 becomes a 500, or an in-flight 413 is masked. The helper's own docstring covers "discard staging after a publication decision", so this site fits it; it is outside this PR's stated census, but it is cheap to fold in here or record as a follow-up.
|
|
||
| temp_root = Path(skills_router.tempfile.gettempdir()) | ||
| before = set(temp_root.glob("deerflow-skill-*")) | ||
| _fail_unlink_on(monkeypatch, lambda path: path.name.startswith("deerflow-skill-"), real_unlink) |
There was a problem hiding this comment.
[nit] Unlike _is_temporary_of above, this predicate matches by name at any directory depth, so during the patched window any concurrent Path.unlink of a deerflow-skill-* path anywhere would also raise. Scoping it with path.parent == temp_root (as the descriptor sites do) removes the cross-suite coupling.
4ed9c88 to
bc0652c
Compare
|
Thanks — all four points addressed at bc0652c. 1. Coordination with #7164 (the blocking one). You were right, and the overlap was larger than my body claimed: #7164 also covers the 2. 3. 4. The nit. Red/green at bc0652c, same method you reproduced before: with the three production files reverted to One thing I should be explicit about, since it is the reason this is a smaller diff than 4ed9c88: the two sites I dropped are genuinely covered by #7164, so the valuable remaining work here is |
…he caller's outcome
bc0652c to
ae30b86
Compare
CI note: the Layer 2 failure was the runner image, not this diff
That is the It is already fixed upstream: #7219 moved The code diff is unchanged by the rebase: same 5 files, |
|
Thanks — status on the four points at head
CI is green on this head (3 checks, combined status success). Could you take another look when you get a chance? |
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up at head ae30b86 (prior review at 4ed9c88, 4 inline threads). Status of the four prior findings at this head — all four addressed:
- [coordination] FIXED. The diff no longer touches
extensions/manager.pyorapp/gateway/routers/skills.py; I verified open #7164 owns exactly those (manager.py +14, skills.py +14), so the two PRs are now genuinely disjoint and neither blocks the other. - [important] FIXED. Both census siblings this review flagged are now in the PR:
lark_broker.py::_write_text_atomically(fdopen outside thewith,fd = Nonetransfer,cleanup_failed_atomic_writeat :489 — the silentexcept OSError: passis gone) anduploads/companions.py::register_companion(same pattern, cleanup at :59), each pinned by an fdopen-failure test, an unlink-failure test, and a quiet-success test. - [suggestion] STILL OPEN on main, correctly out of this diff.
skills.py:583-584is unchanged here and belongs to #7164 like the rest of that file. Answering your thread question: keep it in #7164 — that PR already covers both thefinally-unlink andform.close()there — and don't widen this one. - [nit] FIXED.
_is_temporary_ofnow requirespath.parent == target.parentand_staged_predicatescopes toupload-companions.
Three new inline findings at this head: the lark_cli download path (the last unguarded census site, in a file this PR already fixes — answering your scope question inline), one factually overstated clause in the new uploads/AGENTS.md paragraph, and a likely .startswith/.endswith typo in the new success test. The merge-order concern from the first review is fully resolved; the diff is in good shape.
| os.replace(temporary, path) | ||
| except BaseException: | ||
| Path(temporary).unlink(missing_ok=True) | ||
| cleanup_failed_atomic_write(temporary, fd) |
There was a problem hiding this comment.
[scope — answering your question in the issue thread] This file still carries the last unguarded site from the census: _download_lark_archive (lines 2854-2859 at this head), where both handlers call archive_path.unlink(missing_ok=True) bare, so a failing unlink replaces the download error — the "too large" ValueError in the first handler, the network ValueError in the second. Since this PR already rewrites _publish_bytes_atomically in this same file and imports the helper, folding it in is cheap: make each cleanup best-effort (cleanup_failed_atomic_write(archive_path), or a try/except OSError that logs) and keep the raise bare. Guarding only the unlink while leaving the second handler's collapse-to-one-message as is, is a defensible split exactly as you suggested. If you would rather keep this PR frozen, close the question in the thread by filing the follow-up — it should not remain undecided, since it is the only site left from the #7105 census.
|
|
||
| `companions.py::register_companion` publishes its ownership record through | ||
| `utils.atomic_write.cleanup_failed_atomic_write`. Do not restore a bare | ||
| `finally: os.unlink(staged)`: it runs on the success path too, so a failing |
There was a problem hiding this comment.
[nit — factual] This clause overstates the old bug: the old finally was gated by if os.path.exists(staged), and after a successful os.replace the staged path no longer exists, so the unlink never ran on the success path — a failing unlink could not turn a completed registration into an exception. The genuine defects were the failure-path replacement (an unlink failure after a json.dump or os.replace error replaced the primary error) and the fdopen leak. The "do not restore the finally" guidance itself is right; the recorded reason should be the failure-path one, otherwise a future reader who checks the history will discount the whole note. The new test module's docstring repeats the same overstatement ("even on the success path").
| companions.register_companion(original, markdown) | ||
|
|
||
| records_dir = tmp_path / "thread" / "upload-companions" | ||
| assert [p.name for p in records_dir.iterdir() if p.name.startswith(".json")] or list(records_dir.glob("*.json")) |
There was a problem hiding this comment.
[nit] p.name.startswith(".json") looks like a typo for endswith: records are named {sha256}.json and staged files are .companion-*, so the first list is always empty and the assertion lives entirely on the glob("*.json") fallback. It still passes, but as written it reads as a check that can never fire.
|
All three addressed at
Validation: |
Document that a failed write, fsync, or replace in _publish_bytes_atomically leaves the previous file intact, and that temporary removal must not replace that error, including interruptions. Add site-specific regression tests for unlink and warning-log failures. The cleanup_failed_atomic_write call itself already landed on main in bytedance#7200.
…7329) Document that a failed write, fsync, or replace in _publish_bytes_atomically leaves the previous file intact, and that temporary removal must not replace that error, including interruptions. Add site-specific regression tests for unlink and warning-log failures. The cleanup_failed_atomic_write call itself already landed on main in #7200. Co-authored-by: wannT-1 <200890097+wannT-1@users.noreply.github.com>
Related to #7105. This PR covers the sites that #7106, #7141 and #7164 leave out, and does not close it.
Reorganized after review at 4ed9c88. @willem-bd pointed out that
extensions/manager.py::_write_bytes_atomicallyandapp/gateway/routers/skills.py::_copy_uploaded_skill_archivewere duplicated byte-for-byte in open #7164, and that two same-class siblings were still unfixed in no open PR. This revision drops the two overlapping files entirely (#7164 owns them, and it also covers thefinallyin the upload endpoint plusform.close()), and takes the siblings instead. No file in this diff appears in #7164.Why
Three writers publish through a same-directory temporary and clean it up inside the handler for a failure they are already reporting. Unguarded, two distinct defects appear:
unlinkreplaces the error the caller sees — the real cause survives only as__context__, which the surroundinglogger.warning("...: %s", exc)formats never print;with os.fdopen(fd, ...)that never binds leaksfd, because thewithcannot close a descriptor it never received.uploads/companions.py::register_companionis the strongest case, and the one #7164 does not touch. Its cleanup was a barefinally: os.unlink(staged), which runs on the success path too: a failing unlink turned a completed registration into an exception, and on the failure path it replaced the originaljson.dumperror.lark_broker.py::_write_text_atomicallyis the other:with os.fdopen(...)leaked on anfdopenfailure, and its cleanup failure was swallowed byexcept OSError: pass, leaving an unobservable leftover temporary.What changed
integrations/lark_cli.py::_publish_bytes_atomically—fdopenoutside thewith, ownership transfer (fd = None), cleanup throughutils.atomic_write.cleanup_failed_atomic_write.integrations/lark_broker.py::_write_text_atomically— same, replacingexcept OSError: passso a leftover temporary is reported instead of silently dropped.uploads/companions.py::register_companion— same, replacing the unguardedfinally.uploads/AGENTS.mdrecords why the barefinallymust not come back.Successful publications are unchanged: same bytes, same modes, same rename. Only the failure path differs.
utils/atomic_write.pyis untouched.Surface area
skills/fdopenfailure no longer leaks the descriptor; a failed companion registration now raises the original error instead of the unlink'sBug fix verification
Test path:
backend/tests/test_atomic_cleanup_preserves_primary_error.py(9 cases, no stubbing of the production cleanup helper).main, green on this branch: yes. With the three production files reverted toeb7c342and the tests kept, the file gives 5 failed, 4 passed; at this head, 9 passed. The five failures are exactly the twolark_clicases, the twolark_brokercases and thecompanionscase. The four that pass onmainare deliberate controls: three success paths plus the "success stays quiet" case.os.replace,os.fchmod/os.chmod,os.fdopenandPath.unlink— not by asserting on handler source.lark_brokercase is the one that used to be silent.Validation
Coordination
#7164ownsextensions/manager.pyandapp/gateway/routers/skills.py; this PR does not touch either. Whichever merges first, neither conflicts on content. I have not rebased onto #7164 because the file sets are disjoint.AI assistance
Tool(s) used: DeepSeek Harness (deepseek-v4.1-flash).
How you used it: I asked it to find the remaining unguarded cleanup handlers from the census #7105 published, check each against open PRs so the diff stays disjoint from #7164, and implement the shared-helper change against TDD tests. I reviewed the full diff and both the red-on-
mainand green-on-branch runs myself.