Repository navigation
Conversation
willem-bd
left a comment
There was a problem hiding this comment.
Verified at dc1efca: the three converted sites are correct. cleanup_failed_atomic_write keeps descriptor close, unlink, and the warning log independent best-effort steps, so the original publication error (including KeyboardInterrupt) always surfaces; temporaries stay same-directory for os.replace, and success paths are untouched. The new tests force save failure and cleanup failure together and pin both the surfaced error and the warning (20 passed), and the snapshot-restore suite stays green (8 passed). Follow-ups from a sweep of the head tree: the same shadowing pattern survives in this file's endpoint-level finally unlink (see inline), in the two skills/storage write_custom_skill writers (see inline), plus two small leak windows in manager.py.
| except Exception: | ||
| if destination is not None: | ||
| destination.unlink(missing_ok=True) | ||
| cleanup_failed_atomic_write(destination) |
There was a problem hiding this comment.
Same file, same bug class, one frame up: the finally in upload_and_install_skill (line 589) still does a bare await asyncio.to_thread(temporary_path.unlink, missing_ok=True). missing_ok only tolerates absence, so an EACCES/EBUSY unlink there is not tolerated: after a successful install it turns the response into a 500 (a retry then 409s), and on the 413/400 paths it replaces the mapped error — exactly what this PR fixes inside _copy_uploaded_skill_archive. The install path (ainstall_skill_from_archive) extracts the archive but never removes the uploaded copy, so this unlink runs on the success path too. Suggest routing it through the same helper: await asyncio.to_thread(cleanup_failed_atomic_write, temporary_path) (already imported at line 57).
There was a problem hiding this comment.
Addressed in fdbfbc6d: upload_and_install_skill's finally now discards via await asyncio.to_thread(cleanup_failed_atomic_write, temporary_path), so a failing unlink/warning no longer turns a successful install into a 500 or replaces a mapped 413/400.
| original `BaseException`; pass a raw descriptor only until `fdopen` succeeds. | ||
| `cleanup_failed_atomic_write` shares failure cleanup between this publisher, | ||
| `skills/projection.py`'s private manifest writer, extension snapshot rollback, | ||
| the managed plugins-block rewrite, and the Gateway skill-archive upload copy. |
There was a problem hiding this comment.
Completeness: the PR title says extensions AND skills, but two skills/ atomic writers still have the unguarded-cleanup pattern this PR removes elsewhere. backend/packages/harness/deerflow/skills/storage/local_skill_storage.py:118 and backend/packages/harness/deerflow/skills/storage/user_scoped_skill_storage.py:423 — both write_custom_skill implementations end with finally: tmp_path.unlink(missing_ok=True), so a failed unlink (Windows AV lock, EACCES) replaces the write/replace error. Adjacent: user_scoped_skill_storage._write_skill_states (lines 140-143) preserves the save error but swallows cleanup OSErrors with no warning log, unlike the helper's convention. Worth converting here too or listing them in #7105 so they are not lost.
There was a problem hiding this comment.
Converted in this PR: both write_custom_skill sites and _write_skill_states now go through cleanup_failed_atomic_write (skill-state also warns on cleanup OSError instead of swallowing). Covered by tests/test_skill_storage_atomic_write_cleanup.py; utils/AGENTS.md caller list updated.
| os.replace(temporary, path) | ||
| except BaseException: | ||
| Path(temporary).unlink(missing_ok=True) | ||
| cleanup_failed_atomic_write(Path(temporary)) |
There was a problem hiding this comment.
Nit: small ownership gap the surrounding contract covers. If os.fdopen(fd, "wb") itself raises, the raw descriptor is never closed — this call passes no fd, so the descriptor leaks, and on Windows the subsequent unlink then fails as well. The utils/AGENTS.md rule this PR extends ("pass a raw descriptor only until fdopen succeeds") and publish_bytes_atomically show the intended dance: fdopen outside the with, pass fd only on the fdopen-failure path. Very low likelihood.
There was a problem hiding this comment.
Fixed: _write_bytes_atomically now does fdopen outside the with and passes the raw fd into cleanup_failed_atomic_write only on the fdopen-failure path, matching publish_bytes_atomically / the AGENTS.md note.
| finally: | ||
| if temporary is not None: | ||
| temporary.unlink(missing_ok=True) | ||
| cleanup_failed_atomic_write(temporary) |
There was a problem hiding this comment.
Nit: leak window in this same finally. temporary is assigned (line 999) only after stream.write(updated) completes, so a write failure inside the NamedTemporaryFile with-block — the classic case being ENOSPC — leaves temporary = None, skips this cleanup, and leaks the hidden delete=False temp beside config.yaml. Moving temporary = Path(stream.name) to the first statement inside the with closes it. Pre-existing on main, not a regression.
There was a problem hiding this comment.
Fixed: temporary = Path(stream.name) is assigned as the first statement inside the NamedTemporaryFile with, so an ENOSPC (or other) write failure still hits the finally cleanup.
dc1efca to
fdbfbc6
Compare
|
Rebased onto current
Happy to take another look when you have a moment. |
he-yufeng
left a comment
There was a problem hiding this comment.
Verified on the PR head (fbfbc6d) with the worktree's own environment: all three new suites pass 42/42. The user_scoped_skill_storage.py hunk earns its pin — reverting _write_skill_states to the old unguarded tmp_path.unlink(missing_ok=True) fails test_skill_states_preserve_save_error_when_cleanup_fails[unlink], so the test really does watch the masking behavior.
Two details read correctly: fd = None right after fdopen matches the ownership-transfer contract documented in cleanup_failed_atomic_write (no double-close, and the helper still closes the raw descriptor when fdopen itself never happened), and extending the helper that already covers the channel/project-document/Buzz paths keeps one cleanup semantics across the tree.
Disclosure: my #6770 touches the same file (container_path resolution). The hunks are in different regions (_write_skill_states/write_custom_skill here vs. path resolution there), so no textual or semantic conflict either way.
willem-bd
left a comment
There was a problem hiding this comment.
Verified at fdbfbc6: the previous round is addressed correctly. The upload finally, both write_custom_skill sites and _write_skill_states now go through cleanup_failed_atomic_write; the descriptor is transferred only after fdopen succeeds in _write_bytes_atomically and _write_skill_states; and the plugins temporary path is recorded before the write, so a short write still reaches the cleanup. Success paths, modes and the snapshot-restore contract are unchanged, and the three new suites plus the documented AGENTS.md call sites match the code.
Three residual misses in the same bug class, none a regression: _FileSnapshot.restore's delete branch still unlinks unguarded inside the rollback loops (inline, manager.py), the upload route's finally still lets an unguarded form.close() mask the outcome and skip the archive discard (inline, skills.py), and _write_skill_states keeps except Exception so an interrupt still leaks its temp file (inline, nit).
| await form.close() | ||
| if temporary_path is not None: | ||
| await asyncio.to_thread(temporary_path.unlink, missing_ok=True) | ||
| await asyncio.to_thread(cleanup_failed_atomic_write, temporary_path) |
There was a problem hiding this comment.
Suggestion: the guard added here does not cover await form.close() at line 587, which runs first. FormData.close() closes each UploadFile, and SpooledTemporaryFile.close() can raise (a flush failure on ENOSPC being the realistic case). If it does, the mapped 413/400 or install error is replaced, a successful install still surfaces as a 500, and this discard never runs so the uploaded archive leaks onto disk - exactly the invariant the new routers/AGENTS.md paragraph claims for this finally. Wrap the close in contextlib.suppress(Exception) (or move the cleanup_failed_atomic_write call ahead of it).
There was a problem hiding this comment.
Addressed in a01a001a: discard the archive with cleanup_failed_atomic_write before form.close(), and wrap close in contextlib.suppress(Exception), so a close/flush failure cannot replace the install outcome or skip the discard.
| 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.
Same class, one branch over: _FileSnapshot.restore() (lines 140-144) still does a bare self.path.unlink(missing_ok=True) on the content is None branch - the branch that removes a file the failed install created, e.g. a uv.lock that did not exist before. It runs inside the except BaseException rollback loops (_install lines 329-330 and 342-343, _remove lines 443-444 and 458-459) ahead of the later restores, so an EBUSY/EACCES delete there still replaces operation_error and skips the remaining snapshots (for _remove, the config restore at line 446). Routing it through cleanup_failed_atomic_write(self.path) closes the last masking path in this rollback; the new tests only pin the content is not None branch, so a delete-branch case would cover it.
There was a problem hiding this comment.
Addressed in a01a001a: the content is None branch now uses cleanup_failed_atomic_write(self.path). Added a two-snapshot rollback test covering a failing delete that must not mask the operation error or skip the later restore.
| tmp_path.unlink(missing_ok=True) | ||
| except OSError: | ||
| pass | ||
| cleanup_failed_atomic_write(tmp_path, fd) |
There was a problem hiding this comment.
Nit: this is still except Exception, whereas the two writers this PR converts (_write_bytes_atomically, publish_bytes_atomically) use except BaseException so an interrupt also cleans up. A KeyboardInterrupt during json.dump/replace leaves the hidden .skill_states_*.json.tmp behind (the with handle closes the descriptor, but nothing unlinks the file), and an interrupt in the narrow window before fdopen leaks both descriptor and file. cleanup_failed_atomic_write already tolerates interruptions, so widening the clause would match the extension writers' contract. Low likelihood.
There was a problem hiding this comment.
Addressed in a01a001a: widened to except BaseException so KeyboardInterrupt also cleans through the helper, matching the extension writers. Added a test that interrupt still propagates after cleanup.
|
Pushed |
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up at a01a001 (the one commit since fdbfbc6): all three residuals from the 08:50 review are fixed as described. The upload route's finally now discards the archive through cleanup_failed_atomic_write before form.close() and suppresses close errors (skills.py 585-592), _FileSnapshot.restore's delete branch routes through the helper (manager.py 144) so a failing unlink can't replace the operation error or skip later restores, and _write_skill_states is widened to except BaseException (user_scoped_skill_storage.py 141). The AGENTS.md updates (routers, extensions, skills, utils, tests) match the code, including the discard-before-close and BaseException wording. The four findings from the 05:25 review remain fixed at this head. Verified on a tree built from this head with the repo venv (imports confirmed resolving into that tree): the three touched suites pass 51/51. One nit left, on the new delete-branch test.
| assert "Failed to remove temporary output file" in caplog.text | ||
| assert str(created) in caplog.text | ||
| assert "temporary unlink failed" in caplog.text | ||
| assert "log handler failed" not in str(raised.value) |
There was a problem hiding this comment.
Nit: this assertion is dead where it sits. It only runs for failure == "unlink" — the one param in which the log handler is never patched — so it cannot fail. The check it is aiming at (a failing logging handler must not leak into the surfaced error) belongs under if failure == "logging":, where it would actually bite: there the helper's boom_log RuntimeError is swallowed by the suppress(Exception) around the warning (atomic_write.py 34), and str(raised.value) stays "install failed". As written it reads as coverage for the logging-failure branch while covering nothing.
There was a problem hiding this comment.
Addressed in b8b69c09: moved the log-handler assertion under if failure == "logging": so it actually covers a failing warning handler and keeps str(raised.value) == "install failed".
|
Pushed |
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up at b8b69c0 (one commit since a01a001, +2 lines in the delete-branch test only): the nit is fixed as suggested. The log-handler assertion now sits under if failure == "logging": together with assert str(raised.value) == "install failed" — the one param where boom_log is actually patched — so it now guards the real invariant: the helper's suppress around the warning keeps a failing handler from leaking into the surfaced install error. Nothing else changed since a01a001; all findings from the three earlier review rounds remain fixed. Nothing further from my side.
Extension snapshot rollback, the managed plugins rewrite, and skill-archive upload copies now use cleanup_failed_atomic_write so a failed unlink or warning log cannot mask the original publication error. Co-authored-by: wannT-1 <wannT-1@users.noreply.github.com>
…error Route the skill-archive upload discard, custom-skill file writes, and per-user skill-state writes through cleanup_failed_atomic_write. Close the extension rollback descriptor on fdopen failure, and record the plugins temporary path before the write so a short write is still removed. Co-authored-by: wannT-1 <wannT-1@users.noreply.github.com>
…e error Discard the uploaded skill archive before closing the multipart form, and suppress close errors so a flush failure cannot replace the install outcome. Snapshot rollback of a missing original now uses cleanup_failed_atomic_write, and skill-state writes clean up on BaseException including KeyboardInterrupt. Co-authored-by: wannT-1 <wannT-1@users.noreply.github.com>
…anch error The log-handler check only ran for the unlink parameter, where the handler is never patched. Move it under the logging failure so a handler exception must stay out of the surfaced install error. Co-authored-by: wannT-1 <wannT-1@users.noreply.github.com>
b8b69c0 to
3249de2
Compare
Refs #7105
Sibling follow-up to the channel, project-document, and Buzz cleanup fixes. This does not close #7105; Lark CLI publication is still open.
Why
Unguarded
Path.unlinkinside an atomic-write failure path replaces the save error when cleanup itself fails. The caller then sees the cleanup error (often onlystr(exc)), the original cause survives only as__context__, and a staged file that could not be removed is easy to miss.On current
bytedance/deer-flowmain this was still true for:_write_bytes_atomicallyinextensions/manager.py)plugins:rewrite in the same module (finallyunlink)_copy_uploaded_skill_archive)skills/projection.py_write_manifestalready usescleanup_failed_atomic_writevia merged #6871, so it is not in this diff. Channel publishers (#7106), project documents (#7141), and the Buzz seen-event store (#7157) are left to those PRs.What changed
Those three writers now call
cleanup_failed_atomic_write. Unlink and the warning log are independent best-effort steps, so a cleanup or logging failure cannot mask the original publication error, includingKeyboardInterrupton the extension writers.Successful writes are unchanged: same bytes, same permission bits, same replace-before-visible behavior. A cleanup that succeeds still removes the temporary file and stays quiet.
Surface area
frontend/langgraph.json, or prompt changedocker/or sandboxed executionskills/backend/pyproject.tomlorfrontend/package.jsonBug fix verification
backend/tests/test_extension_atomic_write_cleanup.pybackend/tests/test_skill_archive_upload_cleanup.pymainand green on this branch? yes. With the production writers reverted, the unlink/logging cases failed because the caller observedtemporary unlink failedinstead of the publish/read/size-limit error. With this branch, those cases pass and the existing snapshot-restore suite still passes (28 passed).Validation
cd backend uv run ruff check packages/harness/deerflow/extensions/manager.py app/gateway/routers/skills.py tests/test_extension_atomic_write_cleanup.py tests/test_skill_archive_upload_cleanup.py uv run ruff format --check packages/harness/deerflow/extensions/manager.py app/gateway/routers/skills.py tests/test_extension_atomic_write_cleanup.py tests/test_skill_archive_upload_cleanup.py PYTHONPATH=. uv run pytest tests/test_extension_atomic_write_cleanup.py tests/test_skill_archive_upload_cleanup.py tests/test_extension_snapshot_restore.py -qAI assistance
Tool(s) used: Cursor
How you used it: Implemented the helper reuse and regression tests from the existing
cleanup_failed_atomic_writecontract (#6871 / #7106).