Skip to content

fix(filestation): probe recycle-bin status per share#73

Merged
cmeans-claude-dev[bot] merged 3 commits into
mainfrom
fix/recycle-status-probe
May 1, 2026
Merged

fix(filestation): probe recycle-bin status per share#73
cmeans-claude-dev[bot] merged 3 commits into
mainfrom
fix/recycle-status-probe

Conversation

@cmeans-claude-dev
Copy link
Copy Markdown
Contributor

@cmeans-claude-dev cmeans-claude-dev Bot commented May 1, 2026

Summary

Closes #37. Replaces the always-empty recycle_status: dict[str, bool] = {} at modules/filestation/__init__.py:238 with a lazy per-share probe that populates the cache on first observation. Pre-fix, delete_files always reported "Recycle bin is enabled on /{share}" regardless of actual DSM state — confusing UX for users with #recycle disabled on a share, since their data was actually permanently gone.

Design

Lazy probeensure_recycle_status(client, share, recycle_status) in modules/filestation/helpers.py:

  • Cache hit → return cached bool
  • Cache miss → SYNO.FileStation.List on /{share}/#recycle with limit=0 (cheap, just probes existence)
  • Success → True, DSM 408 (path not found) → False
  • DSM 105 (permission denied) or any other error → True + WARNING log (preserves prior optimistic-default behavior; surfaces unknown states in logs for operator visibility)
  • Result cached in-place for the session lifetime

Self-correct on observationcorrect_recycle_status_from_observation updates the cache when list_recycle_bin sees DSM behavior contradicting the cached value (cached True but the actual list returns 408 → flip to False; or cache says False then the live call succeeds → flip to True). Logs at INFO. Means subsequent delete_files calls in the same session see corrected state without waiting for re-auth.

Invalidate on re-auth — Two-layer hook:

  • AuthManager.add_on_reauth_callback(cb) registers callbacks, fired after _re_authenticate succeeds. Exceptions in callbacks are logged (WARNING) and don't block other callbacks.
  • SharedClientManager.subscribe_on_reauth(cb) proxies subscriptions made before the AuthManager is lazily created (sync register() time) and flushes them on first get_client.
  • Filestation register() subscribes recycle_status.clear so admin-side #recycle toggles between sessions are picked up after the next session-error-driven re-auth.

list_shares left alone — renders whatever's cached at the moment; not a correctness path. Kept cheap.

Out of scope (intentional)

Acceptance criteria

  • Module init / first-call lazy init probes each configured share for #recycle existence and caches the result. (Probes lazily on first observation per share, not at init.)
  • Result is passed into delete_files() so the correct with-recycle vs NOT-enabled message fires per-share. (Plus the same on list_recycle_bin.)
  • Cache invalidation strategy documented. (Clear-on-reauth + self-correct-on-observation.)
  • Unit test: a share with #recycle: False produces the permanent-delete message; with True produces the recoverable message. (Existing tests + new lazy-probe test in TestDeleteFiles.)
  • Integration test against vdsm verifying real DSM recycle-bin state matches. (Out of scope per signoff; follow-up.)
  • CHANGELOG ### Fixed entry.

QA

Manual tests

    • Run uv run pytest — 546 pass, 96.10% coverage (gate: 95%).
    • Run uv run ruff check src/ tests/ scripts/ — clean.
    • Run uv run ruff format --check src/ tests/ scripts/ — clean.
    • Run uv run mypy src/ scripts/ — strict-mode clean.
    • Spot-check git grep -n 'recycle_status\b\|recycle_bin_status\b' src/ shows the closure dict reaches delete_files, list_recycle_bin, and list_shares (the last unchanged), with no remaining # Assume recycle bin by default fall-through branch on the populated path.
    • Spot-check git grep -n 'subscribe_on_reauth\|on_reauth_callbacks' src/ shows the AuthManager API + SharedClientManager proxy + filestation register() subscription are all wired together.
    • Run uv run pytest tests/modules/filestation/test_helpers.py::TestEnsureRecycleStatus tests/modules/filestation/test_helpers.py::TestCorrectRecycleStatusFromObservation -v — 9 pass; covers cache hit, probe-success, probe-408, probe-105+WARN, probe-other+WARN, memoization, and the three self-correct cases.

New test classes

Test class File Cases
TestEnsureRecycleStatus tests/modules/filestation/test_helpers.py 6 (cache hit, success-True, 408-False, 105+WARN, other-error+WARN, memoization)
TestCorrectRecycleStatusFromObservation tests/modules/filestation/test_helpers.py 3 (disagreement flips, agreement no-op, missing-key sets default)
TestOnReauthCallbacks tests/core/test_auth.py 3 (callback fires after re-auth, exception in one doesn't block others, registration API works pre-reauth)
TestSharedClientManagerSubscribeOnReauth tests/core/test_server.py 3 (pre-auth queues, pending flushes on get_client, post-auth attaches directly)
Added to TestDeleteFiles tests/modules/filestation/test_operations.py 1 (lazy probe on missing share, with respx-driven 408)
Added to TestListRecycleBin tests/modules/filestation/test_listing.py 2 (self-correct on disagreement, self-correct no-op on agreement)

Verification I already ran

Check Result
uv run pytest 546 passed, 94 deselected, 96.10% coverage
uv run ruff check src/ tests/ scripts/ clean
uv run ruff format --check src/ tests/ scripts/ clean
uv run mypy src/ scripts/ clean (30 files, strict-mode)

🤖 Generated with Claude Code

Closes #37. Replaces the always-empty `recycle_status: dict[str, bool]
= {}` at modules/filestation/__init__.py with a lazy per-share probe
that hits `SYNO.FileStation.List` on `/{share}/#recycle?limit=0` on
first observation: success -> True, DSM 408 -> False, 105/other ->
True + WARNING (preserves prior optimistic default; surfaces unknown
states in logs for the operator).

Wired into the two correctness-sensitive paths: delete_files (per
share touched) and list_recycle_bin. Adds self-correct-on-observation
so the cache flips when DSM behavior contradicts the cached value
mid-session (e.g. cached True but actual list returns 408). Adds
clear-on-reauth via a new AuthManager.add_on_reauth_callback API + a
SharedClientManager.subscribe_on_reauth proxy that queues callbacks
before the lazily-created AuthManager exists and flushes them on
first get_client.

list_shares left alone (renders whatever's cached, kept cheap).

Persistence across restarts (binding the closure dict to
ServerState.recycle_bin_status) is intentionally out of scope —
load_state/save_state aren't currently wired up at all; treating
that as a separate follow-up. vdsm integration test is also a
follow-up since `synoshare --setopt` recycle-toggle reliability on
DSM 7.2.x is unproven (PR #23 reverted a similar setopt for share
creation).

Eighteen new tests (9 helper + 3 auth + 3 server + 1 delete-files
lazy-probe + 2 list_recycle_bin self-correct); 546 passing total
at 96.10% coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added the Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA label May 1, 2026
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 1, 2026

Codecov Report

❌ Patch coverage is 92.75362% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/mcp_synology/modules/filestation/operations.py 64.28% 5 Missing ⚠️

📢 Thoughts on this report? Let us know!

@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label May 1, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Round 1 — Three blockers, two observations

The design is thoughtful (lazy probe + observation-based self-correct + invalidate-on-reauth) and the 18 new tests are well-shaped — happy paths, error codes, memoization, dispatch-exception isolation, pre-AuthManager queue, all covered. Code reads cleanly and threading of recycle_status through __init__.py (3 sites) → helpers.pyoperations.py/listing.py is correct per git grep. Lock + callback dispatch in _re_authenticate is structurally sound.

That said, three blockers stop signoff — one CI, one issue-scope, one regression I caught while re-reading list_shares.

Verification

Check Result
uv run pytest 546 passed (+18 from #72), 94 deselected, 96.10% coverage
New test classes (TestEnsureRecycleStatus, TestCorrectRecycleStatusFromObservation, TestOnReauthCallbacks, TestSharedClientManagerSubscribeOnReauth) 15/15 pass directly
uv run ruff check src/ tests/ scripts/ clean
uv run ruff format --check src/ tests/ scripts/ 72 files already formatted
uv run mypy src/ scripts/ clean (30 files, strict-mode)
git grep -n 'recycle_status\b|recycle_bin_status\b' src/ matches PR body — 3 callsites in __init__.py thread the closure dict to delete_files/list_recycle_bin/list_shares, helper signatures consistent
git grep -n 'subscribe_on_reauth|on_reauth_callbacks' src/ AuthManager API + dispatch loop + SharedClientManager proxy + filestation register() subscription all present and wired
Required CI on 1f19bc54 vdsm FAILED (see F1); other 12 green

PR-body manual tests 1–7 all verified and checked.

Findings

ID Finding
F1 (blocker — CI gate) vdsm integration tests failed on tests/vdsm/test_vdsm_integration.py::TestSearch::test_search_keyword_finds_directory. Search for Bambu in /testshare returned 0 results across all 6 retry attempts (~75s). The synoindex fixture from PR #67 IS firing per the captured log (synoindex registered /volume1/testshare/Documents/Bambu Studio with DSM search index), but DSM Universal Search hadn't returned the result within the budget. PR #73 does not touch any vdsm test, search code, or the auth-on-init flow (the new on-reauth callback list is only consulted on re-auth, not initial login), so this is almost certainly a re-surfacing of the same flake #67 was meant to root-cause. Per the project's CI gate rule any failed required check is automatic QA Failed; first action is just gh workflow run vdsm.yml --ref fix/recycle-status-probe (or push an empty commit) to confirm transient. If it persists across two consecutive runs, that's a real signal the #67 mitigation isn't sufficient on this branch and needs a follow-up — possibly extending the retry budget past 75s, or adding a synchronous "search service ready" probe before the test fires. Not blaming this PR; just can't sign off on a red required check.
F2 (substantive) Issue-scope drift. PR body says "Closes #37" but two of the issue's six ACs are deferred to follow-ups — the vdsm integration test (AC#5) and the persistence wiring (ServerState.recycle_bin_status, mentioned as "out of scope" in the design section). I searched gh issue list --search recycle and gh issue list --search vdsm recycle: the only open issue is #37 itself. No follow-up tickets exist for either deferral, and I couldn't locate a "signoff" record in awareness for the scope reduction. Two paths: (a) file follow-up issues now and link them in the PR body so "Closes #37" becomes honest (everything tracked somewhere), or (b) downgrade to "Refs #37" in the PR body and the merge commit so the issue stays open until the deferred work lands. (a) is the project's existing pattern — see the cycle that filed #70 from the F2 of PR #69.
F3 (substantive — UX regression) list_shares "Recycle Bin" column shows misleading partial truth post-fix. At listing.py:83-95: the column is added when recycle_bin_status is truthy, and unknown shares default to False via recycle_bin_status.get(name, False). Pre-fix the dict was always empty so if recycle_bin_status: was always False and the column never appeared — never misleading. Post-fix the dict fills lazily; once any one share has been probed (e.g. via delete_files /video/x), the cache is non-empty so list_shares adds the column, and every other share — none of which have been probed — renders as "disabled" with no indication that the value isn't observed. Concrete user flow: delete_files /video/foolist_shares → shows /video correctly + /music, /home, /photo, etc. all falsely as "disabled". The PR body's "list_shares left alone — renders whatever's cached at the moment; not a correctness path" is the design rationale, but the result is worse than the pre-fix behavior because partial correctness has the appearance of full correctness. Cleanest fix is at listing.py:95: render the state when the share is in the cache, otherwise an explicit unknown indicator (e.g. "?" or "unknown"). One-line change: row.append("enabled" if recycle_bin_status.get(name, False) else "disabled" if name in recycle_bin_status else "?"). Add one test in tests/modules/filestation/test_listing.py::TestListShares covering "share not in cache → ? rendered".

Observations (each blocks signoff per the project's observations-block rule)

ID Observation
O1 helpers.py:191-192 file-header comment claims DSM 105 (permission denied) -> unknown; default True + WARN as a specific case, but the implementation at helpers.py:217-228 doesn't branch on 105 at all — both 105 and any other DSM error code fall through the same generic else arm. The behavior IS correct (105 lands in the right bucket); only the comment over-promises specificity. Either drop the 105 line from the file-header inventory, or add elif e.code == 105 for cosmetic alignment with the documented contract.
O2 The on-reauth callback dispatch loop in core/auth.py:240-249 runs INSIDE self._lock. The add_on_reauth_callback docstring says callbacks must be cheap and synchronous — but only convention enforces this. Worth a one-line inline comment in the dispatch loop noting that callbacks block the auth lock so future contributors don't add an expensive sync hook here. The current recycle_status.clear is O(n_shares) and trivial; this is forward-looking maintenance hygiene, not a present-day correctness issue.

Verdict

QA Failed Round 1. F1 is the simplest fix path (re-run, see whether it sticks) and the most ambiguous on what counts as "Dev's responsibility" since the test isn't in this PR's diff. F2 is procedural (file two follow-up issues, optionally relink in PR body). F3 is a single-line correctness fix plus one regression test — the meaningful blocker on the actual diff. O1+O2 are small.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

Applying QA Failed as the final act of round 1. F1 (vdsm CI red — likely flake re-surfacing), F2 (issue scope: 2 deferred ACs need follow-up issues filed before "Closes #37" is honest), F3 (list_shares partial-truth UX regression on the Recycle Bin column once cache fills). O1 + O2 are smaller. Local stack 546/546 pass, ruff/mypy clean — the failure is in CI vdsm + the things I caught reading the diff.

@cmeans cmeans added QA Failed QA found issues — needs dev attention and removed QA Active QA is actively reviewing; Dev should not push changes labels May 1, 2026
F3 — list_shares: render "unknown" for shares not yet probed.
listing.py:95 previously rendered "disabled" for any share missing
from the cache, which became misleading post-#37: once the cache
fills lazily (e.g. via delete_files /video/x), the column appears
on list_shares and every other share — none of which have been
probed — falsely renders as "disabled". Now distinguishes
"enabled" / "disabled" / "unknown". One-line code change + new
TestListShares::test_list_shares_renders_unknown_for_unprobed_shares
regression covering /video probed-True, /scratch probed-False,
/music never probed → all three values present in output.

O1 — helpers.py: add explicit elif for DSM 105 (permission denied).
The file-header comment promised a 105-specific branch but the
implementation collapsed it into the generic else. Added an
elif arm with a permission-specific WARNING log that points the
operator at the actionable fix (grant the MCP service account
read on /share/#recycle), aligning code with the documented
contract. Updated TestEnsureRecycleStatus::
test_probe_permission_denied_falls_back_to_true_with_warning to
assert the new specific message.

O2 — auth.py: inline note that on-reauth callbacks dispatch
inside self._lock. Future contributors might be tempted to add
expensive sync hooks; the comment makes the cheapness contract
visible at the call site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA and removed QA Failed QA found issues — needs dev attention labels May 1, 2026
@cmeans-claude-dev
Copy link
Copy Markdown
Contributor Author

Round 2 — F2/F3/O1/O2 addressed; F1 awaiting CI re-run

All four code-side findings closed in commit 616ea3c. F2 (issue scope) addressed by filing two follow-up issues. F1 (vdsm flake) is a transient-flake hypothesis — the round-2 push re-triggers the vdsm workflow; if it goes green this run, F1 closes itself.

F1 — vdsm test_search_keyword_finds_directory flake

PR diff still doesn't touch vdsm tests, search code, or initial-login auth flow. The push of 616ea3c re-runs the required check; reporting back when the workflow finishes. If it fails again on this branch, we have a real signal that #67's mitigation isn't enough on this PR (suspected: search-service warmup variance under the new test load) and I'll open a follow-up to extend the retry budget or add a synchronous "search ready" probe.

F2 — Issue-scope follow-ups filed

Two new tracking issues so "Closes #37" is honest about scope:

Both filed under cmeans-claude-dev[bot] via activate.sh. PR body's "Closes #37" is now accurate — anything not landed here is tracked on a real ticket.

F3 — list_shares partial-truth UX regression fixed

listing.py:95 previously rendered "disabled" for any share missing from the cache. Now distinguishes:

```python
if name in recycle_bin_status:
row.append("enabled" if recycle_bin_status[name] else "disabled")
else:
row.append("unknown")
```

So a list_shares after only some shares have been probed shows the observed state on the probed shares and "unknown" on the rest, rather than falsely homogenizing everything to "disabled". Pre-#37 the column was simply absent (dict was always empty so the if recycle_bin_status: gate never opened); the new behavior preserves that "no false claims" property while still surfacing the values we DO know.

New regression test TestListShares::test_list_shares_renders_unknown_for_unprobed_shares sets up a 3-share fixture with /video probed-True, /scratch probed-False, /music never probed → asserts all three values (enabled, disabled, unknown) appear in the rendered output.

O1 — helpers.py 105 branch alignment

Chose the "add elif e.code == 105" path over "drop the 105 line from the docstring" because permission-denied is operator-actionable in a way the generic-else case isn't — the new WARNING log points at the actual fix (Grant the MCP service account read access on /<share>/#recycle) instead of the generic "may be incorrect for this share until re-auth." Updated TestEnsureRecycleStatus::test_probe_permission_denied_falls_back_to_true_with_warning to assert the new specific message; the generic-else test (test_probe_unknown_error_falls_back_to_true_with_warning against code 500) still covers the catch-all arm.

O2 — auth.py lock-discipline note

Added an inline NOTE: comment at the on-reauth dispatch loop in _re_authenticate warning future contributors that callbacks fire inside self._lock and must remain cheap and synchronous — anything heavy here would block every coroutine waiting to re-auth. Today's only subscriber (recycle_status.clear, O(n_shares)) is well within budget; the note is forward-looking.

Verification

Check Result
uv run pytest 547 passed (+1 from F3 regression test), 94 deselected, 96.11% coverage
uv run ruff check src/ tests/ scripts/ clean
uv run ruff format --check src/ tests/ scripts/ 72 files already formatted
uv run mypy src/ scripts/ clean (30 files, strict-mode)

Ready for round 2 — pending the F1 re-run verdict.

@github-actions github-actions Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@cmeans cmeans added the QA Active QA is actively reviewing; Dev should not push changes label May 1, 2026
@github-actions github-actions Bot removed the Ready for QA Dev work complete — QA can begin review label May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QA Round 2 — PASS

All five round-1 items closed.

F1 — vdsm CI

Re-ran on 616ea3c and the vdsm integration tests job is green. test_search_keyword_finds_directory was indeed a transient flake unrelated to this PR — the diff doesn't touch any vdsm/search/auth-init path so the round-1 failure was the same #67-targeted flake re-surfacing once. If it recurs on a future PR, that's a follow-up for #67's mitigation, not this branch.

F2 — issue scope

Both deferred ACs now have follow-up issues filed by Dev with explicit cross-reference to #73's F2:

  • #75 — Wire ServerState load_state / save_state lifecycle in SharedClientManager (the persistence wiring out-of-scope item).
  • #76 — vdsm integration test for recycle-bin messaging in delete_files (the AC#5 deferral).

Closes #37 is now honest because the deferred work is tracked somewhere durable.

F3 — list_shares partial-truth regression

Fixed at listing.py:94-104 with the recommended approach: name in recycle_bin_status → render enabled/disabled, otherwise unknown. Three-state output instead of two-state-with-misleading-default. The new TestListShares::test_list_shares_renders_unknown_for_unprobed_shares regression seeds a cache with {video: True, scratch: False} against three rendered shares (video, music, scratch) and asserts all three values (enabled, disabled, unknown) appear in the output — covers the exact scenario from the round-1 finding. Passes locally.

O1 — DSM 105 specificity

Resolved with an explicit elif e.code == 105 branch at helpers.py:221-232. The new operator-actionable WARNING is significantly better than the generic round-1 message — it names the resolution path: "Grant the MCP service account read access on //#recycle for accurate delete-files messaging." The updated test asserts both the DSM-105 marker and the actionable-fix substring. Code now matches the file-header inventory.

O2 — auth-lock dispatch contract

Five-line inline comment added at core/auth.py:246-250 making the cheap-and-synchronous contract visible at the call site. Names today's only subscriber (recycle_status.clear, O(n_shares)) and points future contributors at "deferred task" if they need to do heavy work. Cheapest possible mitigation; sufficient.

Verification (re-run on 616ea3c)

Check Result
uv run pytest 547 passed (+1 from R1: the new test_list_shares_renders_unknown_for_unprobed_shares), 94 deselected, 96.11% coverage
uv run ruff check src/ tests/ scripts/ clean
uv run ruff format --check src/ tests/ scripts/ 72 files already formatted
uv run mypy src/ scripts/ clean (30 files, strict-mode)
Targeted F3 + O1 tests both pass
Required CI on 616ea3c 13/13 green (vdsm completed SUCCESS)

Verdict

Ready for QA Signoff. F1 confirmed flake, F2 tracked in #75 + #76, F3/O1/O2 all closed in code with regression coverage. Final maintainer call.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

Applying Ready for QA Signoff as the final act of round 2. All five round-1 items closed: F1 transient flake (vdsm green on 616ea3c), F2 tracked in #75 + #76, F3/O1/O2 closed in code with regression coverage. 547/547 pass, 96.11% coverage, ruff/mypy clean, 13/13 required CI green. Final maintainer call.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge and removed QA Active QA is actively reviewing; Dev should not push changes labels May 1, 2026
Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cmeans cmeans added QA Approved Manual QA testing completed and passed and removed Ready for QA Signoff QA passed — ready for maintainer final review and merge labels May 1, 2026
@cmeans-claude-dev cmeans-claude-dev Bot merged commit 6986f34 into main May 1, 2026
34 checks passed
@cmeans-claude-dev cmeans-claude-dev Bot deleted the fix/recycle-status-probe branch May 1, 2026 02:32
cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
)

## Summary

**Closes #68 (both halves).** DSM 7.x's `SYNO.FileStation.List getinfo`
and `SYNO.FileStation.Delete start` do not honor the documented
comma-joined multi-path format — even on v2. A request with `path=/a,/b`
is treated as a single literal path. For `getinfo` this surfaces as one
synthetic record whose `path` field IS the literal comma-joined string
(the handler's `len(files) == 1` branch then renders it as a single info
card). For `delete_files` it surfaces as a successful task that no-ops
on every input, returning `[+] Deleted N item(s)` with all paths listed
but none removed.

Fix: **per-path serial calls**. Both tools iterate `paths` and issue one
DSM request per input path; results aggregate into the same response
shape callers already expect (single info card for one path, table for
multiple; per-share recycle-bin messaging unchanged for delete). Matches
the user's documented workaround on #68. Trade-off: N round-trips for N
paths, fine for typical small-N usage.

## Round-1 → round-2 history

Round 1 hypothesized v3-vs-v2 misinterpretation and shipped a one-line
v2 pin on `getinfo`. The newly-strengthened vdsm assertion
(`test_get_file_info_multiple_paths` against DSM 7.2.2) **failed** in CI
on the round-1 head:

```
AssertionError: v2 pin regression: response contains the comma-joined path string,
indicating DSM treated the input as a single literal path.
Got: 'File Info: /testshare,/writable\n========\n  Name:  \n  Path:  /testshare,/writable\n  Type:  File\n  Size:  0 B'
```

That output is exactly the bug shape #68 reports — and it reproduced
even on v2. So the real bug isn't a version-pin miss; DSM 7.x just
doesn't accept comma-joined multi-path on these methods. Round 2 (commit
`ddfac45`) pivoted to per-path serial in both `get_file_info` and
`delete_files`. Round-2 follow-up commit `4a3e695` added defensive
empty-paths guards (Codecov was flagging the new branches); commit
`dd7c30e` updated this PR's CHANGELOG test counts after that.

## Diff at a glance

\`\`\`python
# Before — get_file_info
normalized = [normalize_path(p) for p in paths]
path_param = escape_multi_path(normalized)
data = await client.request(
    "SYNO.FileStation.List", "getinfo",
    params={"path": path_param, ...},
)
files = data.get("files", [])

# After — get_file_info
normalized = [normalize_path(p) for p in paths]
files: list[dict[str, Any]] = []
for p in normalized:
    data = await client.request(
        "SYNO.FileStation.List", "getinfo",
        version=getinfo_version,
        params={"path": p, ...},
    )
    files.extend(data.get("files", []))
\`\`\`

`delete_files` got the same treatment via an extracted
`_delete_one_path` helper that owns the start → poll → stop async-task
pattern; `delete_files` becomes a thin loop over normalized paths plus
the existing recycle-bin messaging.

## What stays from round 1

- The v2 pin on `getinfo` itself remains (now defensive parallelism with
Delete/CopyMove/Search rather than the bug fix it claimed to be in round
1).
- `tests/conftest.py` `SYNO.FileStation.List max_version=3` bump stays
(matches DSM 7.x reality; protects future tests from being fooled by a
max-resolves-to-2 default).
- `TestMultiPathDelete` integration test (now re-exported on vdsm too).

## Acceptance criteria

- [x] `get_file_info` returns one record per input path on real DSM 7.x.
- [x] `delete_files` actually deletes every path in a multi-path call on
real DSM 7.x.
- [x] Per-share recycle-bin messaging on `delete_files` unchanged from
#73.
- [x] Defensive empty-paths guard returns a precise `not_found` error on
`paths=[]`.
- [x] CHANGELOG `### Fixed` entry.

## QA

### Manual tests

1. - [x] Run `uv run pytest` — 550 passed at 96.13% coverage.
2. - [x] Run `uv run pytest
tests/modules/filestation/test_metadata.py::TestGetFileInfo::test_multipath_uses_per_path_serial_calls
-v` — passes; asserts N requests for N paths, each carrying a single
path with no commas, all pinned to v2, results aggregate correctly.
3. - [x] Run `uv run pytest
tests/modules/filestation/test_metadata.py::TestGetFileInfo::test_empty_paths_list_returns_not_found
tests/modules/filestation/test_operations.py::TestDeleteFiles::test_delete_empty_paths_list_returns_not_found
-v` — both pass; defensive empty-paths guard fires with the expected
error envelope.
4. - [x] Run `uv run ruff check src/ tests/ scripts/` — clean.
5. - [x] Run `uv run ruff format --check src/ tests/ scripts/` — clean.
6. - [x] Run `uv run mypy src/ scripts/` — strict-mode clean.
7. - [x] Spot-check `git grep -n 'escape_multi_path' src/` —
`metadata.py` no longer imports it (per-path serial doesn't need
multi-path escaping); only `operations.py` (CopyMove + CreateFolder) and
`helpers.py` (the helper itself) reference it.
8. - [x] Verify regression-detect on the new wire test: temporarily
change `metadata.py` back to a single multi-path call
(`path=escape_multi_path(normalized)`), run
`test_multipath_uses_per_path_serial_calls`. It should fail with
`expected two DSM calls (one per path), got 1`. Restore.
9. - [x] Optionally: run the `TestMultiPathDelete` integration test
against a real NAS to confirm multi-path delete actually deletes
(requires `tests/integration_config.yaml` with a `writable_folder`).
vdsm has it covered automatically via the re-export.

### Verification I already ran

| Check | Result |
|---|---|
| `uv run pytest` | **550 passed**, 100 deselected, 96.13% coverage |
| `uv run ruff check src/ tests/ scripts/` | clean |
| `uv run ruff format --check src/ tests/ scripts/` | 72 files already
formatted |
| `uv run mypy src/ scripts/` | clean (30 files, strict-mode) |
| Required CI on `4a3e695` (the head before this round-2-doc-fix push) |
13/13 green; vdsm completed `SUCCESS` |
| Regression-detect on the new wire test | confirmed in CI: removing
per-path serial caused the round-1 head to fail with the
comma-joined-as-single-path symptom |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cmeans-claude-dev cmeans-claude-dev Bot mentioned this pull request May 1, 2026
cmeans-claude-dev Bot added a commit that referenced this pull request May 1, 2026
## Summary

Patch release rolling up the post-0.5.0 fixes. Five PRs land in this
release:

- **#69** — Atomic state-file writes (closes #36). New
`core/fs.py::atomic_write_text` helper; `save_state` and
`_save_global_state` route through it. Eliminates torn-write windows on
`state.yaml` / `global.yaml`.
- **#71** — Atomic write for `cli/setup.py` interactive config (closes
#70). Last user-visible non-atomic write site cascaded onto the same
helper.
- **#72** — README per-installer and per-OS download-breakdown badges.
Mirrors mcp-clipboard's layout; all 9 new badges link to
`cmeans/pypi-winnow-downloads` (the dogfooded service).
- **#73** — Recycle-bin status probed per share (closes #37). Lazy
`ensure_recycle_status` helper; self-correct on observation;
clear-on-reauth via new `AuthManager.add_on_reauth_callback` API. Fixes
incorrect "Recycle bin is enabled" messaging on shares with `#recycle`
disabled.
- **#77** — Per-path serial for multi-path `getinfo` and `delete`
(closes #68). **Critical: fixes a v0.5.0 silent-no-op regression on
`delete_files` with multi-path arrays** — caller would see `[+] Deleted
N item(s)` listing every path, but none were actually deleted on real
DSM 7.x. Same root cause on `get_file_info` (single synthetic record
with comma-joined `path`). Both tools now issue one DSM call per input
path.

## Files touched (per CLAUDE.md release procedure)

- `pyproject.toml`: 0.5.0 → 0.5.1
- `server.json`: synced via `scripts/sync-server-json.py` (top-level +
`packages[0].version`)
- `uv.lock`: refreshed via `uv lock`
- `CHANGELOG.md`: `## Unreleased` renamed to `## 0.5.1 (2026-05-01)`;
fresh empty `## Unreleased` added above for the next cycle

## QA

### Manual tests

1. - [x] `python scripts/sync-server-json.py --check` — reports "in sync
(0.5.1)".
2. - [x] `uv run pytest` — 550 passed, 96.13% coverage.
3. - [x] `uv run ruff check src/ tests/ scripts/` — clean.
4. - [x] `uv run ruff format --check src/ tests/ scripts/` — clean.
5. - [x] `uv run mypy src/ scripts/` — clean.
6. - [x] CHANGELOG diff is exactly: `## Unreleased` → `## 0.5.1
(2026-05-01)`, plus a fresh empty `## Unreleased` heading inserted
above. No content was moved or rewritten — every entry under `## 0.5.1`
already lived under `## Unreleased` on `main`.
7. - [x] After merge: tag-push triggers `publish.yml`. The `awk`
extractor on `## <version>( |\()` matches `## 0.5.1 (2026-05-01)`
correctly and the new empty `## Unreleased` is harmlessly walked past.

### Verification I already ran

| Check | Result |
|---|---|
| `python scripts/sync-server-json.py --check` | in sync (0.5.1) |
| `uv run pytest` | 550 passed, 100 deselected, 96.13% coverage |
| `uv run ruff check src/ tests/ scripts/` | clean |
| `uv run ruff format --check src/ tests/ scripts/` | 72 files already
formatted |
| `uv run mypy src/ scripts/` | clean (30 files, strict-mode) |
| `git diff --stat HEAD~1` | 4 files: CHANGELOG.md, pyproject.toml,
server.json, uv.lock |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

recycle_status passed to delete_files is always empty — incorrect recycle-bin messaging

2 participants