Repository navigation
fix(config): keep atomic-write cleanup from replacing the caller's outcome - #7279
Conversation
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed at head 6716212 (config slice of the atomic-cleanup family: #7200 merged; #7229/#7164 still open). All paths and line numbers validated against the head tree, not a local checkout.
Verdict: correct fix, right mechanism; one comment-accuracy nit inline.
- Adopts, not duplicates: both modules import the shared
cleanup_failed_atomic_writefromdeerflow.utils.atomic_write. #7200 is merged into main, so there is no collision or local reimplementation. - Census (config layer, this head): the three bare-unlink sites are exactly
ManagedModelStore._writeand the twosecret_filefinally blocks — all fixed here. The fourth config writer,atomic_write_extensions_config(config/extensions_config.py:748-757), already guards its unlink with try/except OSError + warning log, which satisfies the family convention, so leaving it alone is fine. There is no runtimeconfig.yamlwriter inbackend/app(no yaml-dump path exists), so nothing else in scope is missed; the PR body's "these three sites" claim is accurate for bug-carrying sites. - fd ownership: all three call sites pass
fd=Noneand the descriptor is closed before the finally —NamedTemporaryFile's context manager inmanaged_models._write, explicit innerfinally: os.close(fd)in bothsecret_filepaths. Matches the family rule that fd is transferred only while actually owned. - Tests verified red/green: ran
backend/tests/test_config_atomic_cleanup.pywith PYTHONPATH pinned per tree — imports confirmed resolving into each tree; against base 29ba806 all 4 failed, against head 6716212 all 4 passed. The PR body's "4 failed → 4 pass" reproduces exactly. - Coordination for the family: the same bare pattern still exists outside the config module at this head — notably
extensions/manager.py:117(unguardedPath(temporary).unlink(missing_ok=True)insideexcept BaseException:beforeraise, i.e. exactly the masking this PR fixes) andextensions/manager.py:1003, pluspersistence/agents/file.py:257,persistence/managed_subagents/file.py:100, and the skills-storage writers. Out of this PR's declared scope;extensions/manager.pyoverlaps open #7164. Worth a follow-up census PR so the long tail lands somewhere instead of fading.
| return True | ||
| finally: | ||
| Path(temporary).unlink(missing_ok=True) | ||
| # The value is already published through the hard link, so removing our |
There was a problem hiding this comment.
The suppression is correct on every exit path, but the stated reason only covers one of them. This finally is also reached when _write_all or os.close(fd) failed (nothing was published at all) and via line 149, where a hard-link-less filesystem publishes through _publish_under_claim's rename — not through a hard link. A reader taking the comment at face value could conclude the unlink is only ever reached after successful publication. Suggest wording that states the real invariant, e.g.: "Removing our own temporary name is best-effort on every exit path — whether the link published the value, a peer won the name, or an earlier step already failed — and must never mask that outcome." (Same accuracy bar as the #7200 AGENTS.md overstatement follow-up: the docstring should not promise more than the code guarantees.)
|
Comment accuracy fixed at finally:
# Removing our own temporary name is best-effort on every exit path —
# whether the hard link published the value, a peer won the name, a
# hard-link-less filesystem published through the replacement claim, or
# an earlier step already failed — and must never mask that outcome.
cleanup_failed_atomic_write(Path(temporary))I applied the same accuracy bar to the test module's docstring, which also said "after the value is already published": it now covers every exit path and says a cleanup error must not replace either a published value or an earlier error. On the census note: agreed, and thanks for the exact sites — Re-verified after the wording change: |
|
All three points from your review are addressed at
The census follow-up you listed ( |
Why
secret_file._create_exclusivepublishes the generated value by hard-linking the temporary file onto the final name, then removes its own temporary name in afinally. When that removal fails — a scanner still holding the handle on Windows, a read-only mount, a peer on SMB — the function raises after the secret is already published, so a credential file that was created successfully is reported as a failure._publish_under_claimhas the same shape on its abort path, andManagedModelStore._writelets the cleanup error mask a failedos.replace, hiding the reason the write did not land.The repository already owns a helper for this:
deerflow.utils.atomic_write.cleanup_failed_atomic_write, documented as "Release an owned temporary without replacing the caller's outcome", already used byintegrations/lark_cli.py,integrations/lark_broker.pyanduploads/companions.py. These three sites are the ones that still bypass it.What changed
Those three cleanup calls now go through the shared helper, which logs an
OSErroras a warning and leaves the caller's outcome intact. No new abstraction, no signature, format or permission change.Surface area
No user-facing surface change — this is the cleanup path of two config writers. None of the template checkboxes apply.
Bug fix verification
backend/tests/test_config_atomic_cleanup.pymainand green on this branch? yes — 4 failed → 4 passed.PermissionErrorfromPath.unlinkand asserts the specific outcome survives: the published secret value for the success paths, and the originalOSErrormessage ("publish failed" in str(...)) for the abort paths. Asserting merely "an OSError was raised" would pass before and after the fix.Validation
AI assistance
Tool(s) used: DeepSeek Harness (agentic coding agent)
How you used it: the agent located the three sites with a grep pass over
finally-block cleanup patterns, wrote the failing tests first, then the fix. I reviewed the diff and ran the checks above.Validation:
pytest tests/test_secret_file.py tests/test_managed_models.py tests/test_config_atomic_cleanup.py -q-> 44 passed;ruff checkandruff format --checkclean. The two new tests fail onmain(the cleanup error replaces the published value / the originalOSError) and pass here.