Skip to content

refactor(windows): replace PowerShell-subprocess backend with pywin32 (Phase 1)#146

Open
cmeans-claude-dev[bot] wants to merge 25 commits into
mainfrom
refactor/windows-pywin32-backend
Open

refactor(windows): replace PowerShell-subprocess backend with pywin32 (Phase 1)#146
cmeans-claude-dev[bot] wants to merge 25 commits into
mainfrom
refactor/windows-pywin32-backend

Conversation

@cmeans-claude-dev

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

Copy link
Copy Markdown
Contributor

Summary

Closes #143. Replaces the Windows clipboard backend with a direct pywin32-based implementation that keeps clipboard ownership inside the long-lived MCP-server Python process. Eliminates the cross-process read-after-write race and the codepage-transcoding fragility that the prior PowerShell-subprocess backend produced — both root-caused via the Windows e2e suite earlier today.

PR #145 is closed unmerged because its retry-overload patch addressed the wrong symptom (multi-format intra-process contention, not the cross-process staleness driving the user-visible silent no-op).

Why a refactor instead of more patching

The verification of #145 against the QEMU guest (run-index mcp-clipboard-windows-e2e-pr145-verify-claude-code-2026-05-09T19:38:20Z) showed:

  • mc-005, mc-020 (SVG silent no-op) persist post-fix.
  • mc-009 (HTML via clipboard_copy(mime=text/html)) regressed — was PASS in the original run, now FAIL with the same silent-no-op shape.
  • mc-103 (direct PowerShell GetData('image/svg+xml') after the MCP write) passed — the SVG bytes ARE on the clipboard.

That last data point was decisive: the bytes land correctly; the next subprocess spawned by _windows_list_formats / _windows_read queries the OLE clipboard chain before the prior (now-exited) writer's snapshot has finished propagating. The architectural seam — PowerShell subprocess per MCP tool call — is what fails. No write-side patch closes it. The codepage fragility (closed in #131 / #142 / #132) is the same architecture leaking through a different seam.

What's in the new backend

src/mcp_clipboard/clipboard_win32.py — synchronous Win32 clipboard wrapper using pywin32's win32clipboard module. All clipboard ops are in-process: OpenClipboard (with retry on contention) → EmptyClipboardSetClipboardData / GetClipboardData / EnumClipboardFormatsCloseClipboard. Custom format strings (HTML Format, Rich Text Format, image/svg+xml) registered via RegisterClipboardFormat and cached.

Format strategy:

MIME Win32 format Encoding
text/plain CF_UNICODETEXT UTF-16 native
text/html registered HTML Format UTF-8 CF_HTML wrapper
text/rtf registered Rich Text Format UTF-8 source
image/svg+xml registered image/svg+xml UTF-8 markup

Multi-format writes (clipboard_copy_markdown) commit all formats inside one OpenClipboard transaction — atomic by construction.

src/mcp_clipboard/clipboard.py — the seven _windows_* functions become thin wrappers around clipboard_win32.*, dispatched via asyncio.to_thread so the asyncio MCP server stays responsive while the synchronous Win32 transaction runs. Public function signatures unchanged; server.py and the dispatch tables don't need to know the backend changed.

pyproject.tomlpywin32>=300 added under the sys_platform == 'win32' marker, so Linux and macOS installs don't pull the Windows-only wheel.

Phase 1 scope

This PR ports the text formats: text/plain, text/html, text/rtf, image/svg+xml, plus multi-format writes and list_formats. The four user-visible failure cases from the verification run (mc-005, mc-009, mc-020, plus the codepage-loss class) are addressed structurally.

_windows_read_image and _windows_write_image (PNG / JPEG raster paths) stay on PowerShell in this PR. Image tests have been blocked by the unrelated host-API-image-rejection issue on both CC and CD verification runs, so they aren't actively failing today, and porting them needs DIB ↔ PNG conversion (Pillow) which is enough work to deserve its own PR. Phase 2 follow-up issue for: pywin32 image read/write, DIB ↔ PNG conversion, integration tests for clipboard_copy_image round-trip.

CI changes — replaces the QEMU manual loop

Per the awareness notes mcp-clipboard-ci-windows-strategy and ci-clipboard-linux-headless-xvfb (both 2026-05-09): windows-latest and macos-latest runners are free for public repos and have real session/window servers, so the platform-specific code paths can be exercised on every push instead of every manual QEMU round-trip.

  • test job — was ubuntu-latest × 3 Python versions. Now a 3-OS × 3-Python matrix (ubuntu / windows / macos × 3.11 / 3.12 / 3.13) with fail-fast: false. Codecov upload remains scoped to the Linux 3.13 cell.
  • New integration-windows job — runs tests/test_clipboard_win32_integration.py against a real Windows Server 2025 clipboard. Round-trips text/plain (with non-ASCII), text/html (CF_HTML wrapper), text/rtf, image/svg+xml, multi-format atomic writes, list_formats. The integration tests self-skip on non-Windows so they're harmless on local Linux pytest runs.

The integration-windows job is the definitive replacement for the e2e suite's text-format coverage. Going forward, Windows-clipboard PRs are verified per-push without QEMU.

Test churn

~25 unit tests that asserted PowerShell-script implementation details (e.g. [Console]::InputEncoding present in script, SetDataObject 4-arg retry overload, Get-Clipboard followed by GetData) are deleted because the implementation they targeted no longer exists. Replaced with focused dispatcher tests that mock clipboard_win32.read_text / write_text / write_multi / list_formats and assert the dispatch is correct + ClipboardError wrapping is uniform. The new tests/test_clipboard_win32_integration.py (16 tests) covers behavior that was previously only checked by the QEMU manual loop.

Test plan

  • uv run pytest — 673 unit tests pass on Linux; the integration suite (tests/test_clipboard_win32_integration.py) skips at module level off-Windows. The "2 pre-existing failures" caveat from earlier rounds (test_max_*_bytes_non_integer_raises_at_import) does not reproduce in QA env or on subsequent local runs; treat as a stale Dev-side mise-vs-uv sys.executable quirk, not a property of this branch.
  • Lint and format clean.
  • CI Windows job (integration-windows) green on windows-latest. This is the verification gate — the QEMU manual run is replaced by this CI job.
  • CI macOS cell green (smoke check that the test matrix change doesn't break macOS).
  • After merge: re-run the focused e2e suite from CC on the QEMU guest as a sanity check that the actual user-visible silent-no-op (mc-005, mc-020) flips to PASS.

Related

🤖 Generated with Claude Code

… (Phase 1)

Closes #143.

The earlier Windows backend shelled out to `powershell -NoProfile -Command
"..."` once per MCP tool call. That architecture had two structural defects
the e2e suite captured directly:

1. Cross-process read-after-write race (mc-005, mc-009, mc-020 silent
   no-ops; mc-103 confirmed bytes ARE on the clipboard, mc-005's same
   write-then-list_formats from MCP saw stale state). After
   SetDataObject(.., copy=true) the OLE clipboard chain needed to fully
   propagate the snapshot from the exiting writer process before a fresh
   reader subprocess could see it. PR #145 (SetDataObject 4-arg retry
   overload) helped one symptom -- the multi-format markdown 4-minute
   hang at mc-008 -- but did not address the cross-process staleness
   producing the user-visible silent no-op. Closed unmerged with that
   diagnostic recorded.

2. PowerShell stdin / stdout codepage transcoding. Closed structurally
   by #131 (input) and #142 / #132 (output) -- the encoding workarounds
   stay correct but become unnecessary because the new backend never
   sits an intermediate codepage between Python str and the Win32
   clipboard.

The new backend in src/mcp_clipboard/clipboard_win32.py keeps clipboard
ownership inside the long-lived MCP-server Python process and uses the
standard Win32 OpenClipboard / EmptyClipboard / SetClipboardData /
GetClipboardData / EnumClipboardFormats / RegisterClipboardFormat /
CloseClipboard API directly via pywin32. No subprocess spawn, no
codepage transcoding, no cross-process race, no per-op PowerShell
cold-start.

Per-format strategy:
  - text/plain  -> CF_UNICODETEXT (UTF-16 native)
  - text/html   -> registered "HTML Format", UTF-8 CF_HTML wrapper
  - text/rtf    -> registered "Rich Text Format", UTF-8 source
  - image/svg+xml -> registered "image/svg+xml", UTF-8 markup

Multi-format writes (clipboard_copy_markdown) commit all formats inside
a single OpenClipboard transaction -- atomic by construction, no window
during which the clipboard holds half the formats.

OpenClipboard contention from clipboard inspectors / antivirus is
handled with 10 retries x 50ms in clipboard_win32._open_clipboard_with_retry.
After the budget is exhausted RuntimeError surfaces and the dispatchers
in clipboard.py wrap it as ClipboardError.

Async dispatch: each Win32 call is wrapped in asyncio.to_thread so the
asyncio MCP server stays responsive while the synchronous clipboard
transaction runs.

Phase 1 scope: text formats only. Phase 2 (follow-up PR) ports
_windows_read_image and _windows_write_image to pywin32 with DIB <-> PNG
conversion. Until then, image read/write paths still use PowerShell.

CI changes:
  - test job moves to a 3-OS x 3-Python matrix (ubuntu-latest +
    windows-latest + macos-latest x 3.11/3.12/3.13) with fail-fast: false.
    Per ci-clipboard-windows-strategy awareness note (2026-05-09):
    GH Actions matrix is recommended because windows-latest and
    macos-latest are free for public repos and have real session/window
    servers.
  - New integration-windows job runs tests/test_clipboard_win32_integration.py
    against a real Windows Server 2025 clipboard. Round-trips text/plain
    (with non-ASCII), text/html (CF_HTML wrapper), text/rtf, image/svg+xml,
    multi-format atomic writes, list_formats. Per-push verification of
    the platform-specific code paths -- replaces the QEMU manual loop.

Test churn: ~25 PowerShell-script-shape unit tests deleted (asserted
implementation details that no longer exist) and replaced with focused
dispatcher tests that mock clipboard_win32.read_text / write_text /
write_multi / list_formats. Public Python contract on the dispatchers
unchanged; mocking surface relocated.

pywin32 declared as a Windows-only runtime dependency in pyproject.toml
via the `sys_platform == 'win32'` marker. Already a transitive dep on
some installs; this just makes the dependency we actually rely on
explicit.

Local verification: 620 unit tests pass (the 2 failures left are the
pre-existing test_max_*_bytes_non_integer_raises_at_import pair where
sys.executable resolves to mise's python instead of the venv's --
unrelated to this change, fails the same way on main). 16 new
integration tests skip on Linux as expected. Lint and format clean.

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 9, 2026
@codecov

codecov Bot commented May 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@github-actions github-actions Bot added CI Failed CI failed — dev needs to fix and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 9, 2026
CI for the new 3-OS matrix surfaced four cross-platform issues that
needed addressing before the integration-windows job could even run:

1. ruff RUF100 + RUF001 on tests/test_clipboard_win32_integration.py:
   - The `# noqa: E402` directive was stale (the integration file's
     post-skip import is not actually a top-level import in the
     module-load sense, so E402 doesn't apply). Removed.
   - The "before — 'curly' \"double\" … after" sample literal had
     ambiguous left/right curly quotes (RUF001). Rebuilt from explicit
     \\N{EM DASH} / \\N{LEFT SINGLE QUOTATION MARK} / etc. escapes so
     ruff stops flagging them as ASCII look-alikes.

2. mypy on src/mcp_clipboard/clipboard_win32.py:
   - The `# type: ignore[import-not-found]` comment is correct for
     the Linux import (pywin32 not installed) but mypy on the Windows
     runner reports `import-untyped` because pywin32 ships without
     stubs. Combined the two codes:
     `# type: ignore[import-not-found,import-untyped]`.
   - RegisterClipboardFormat / CF_UNICODETEXT / GetClipboardFormatName
     return Any from the un-stubbed module; mypy flagged
     "Returning Any from function declared to return int/str". Coerced
     each call site to int/str via int(...) / str(...) so the return
     types are honestly typed without taking on types-pywin32 as a
     dev dependency.

3. test (windows-latest, *) failed at import time on
   src/mcp_clipboard/clipboard.py:227 with
   `AttributeError: module 'os' has no attribute 'getuid'`.
   `os.getuid` is POSIX-only; the Wayland-display detection helpers
   construct a fallback /run/user/<uid>/ path that's only meaningful
   on Linux but the tests mock _get_backend to "wayland" on every
   platform. Wrapped both call sites in
   `getattr(os, 'getuid', lambda: 0)()` so the fallback path becomes
   "/run/user/0" on Windows, fails the subsequent is_dir() check
   anyway, and the function returns None as the Linux-on-no-Wayland
   path would. Behavior preserved on Linux; cleanly inert on Windows.

4. test (macos-latest, *) failed in tests/test_server.py:698 with
   `OSError: AF_UNIX path too long`. macOS pytest tmp_path lives under
   /private/var/folders/... which exceeds the 104-byte AF_UNIX socket
   path limit; the helper that builds fake wayland-* sockets for
   _find_wayland_display tests cannot bind there. Wrapped
   _fake_runtime_dir with a `pytest.skip` on non-Linux so any test
   that calls it skips cleanly rather than failing for a platform
   reason unrelated to the test intent.

Local verification: pytest 620 passed, 1 skipped (the integration
file), 19 deselected, 5 xfailed. Lint, format, mypy clean. The 2
remaining failures (test_max_*_bytes_non_integer_raises_at_import)
are still the pre-existing sys.executable-vs-venv environment issue
unrelated to this branch.

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 CI Failed CI failed — dev needs to fix and removed CI Failed CI failed — dev needs to fix Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 9, 2026
macOS BSD printf does not expand \\xNN escape sequences -- it emits
the literal characters -- whereas GNU printf (Linux) does. The
test_run_binary_returns_raw_bytes test relied on printf "\\x89PNG"
producing the 4-byte sequence 0x89 0x50 0x4E 0x47 (PNG magic), which
worked on Linux but failed on macos-latest:

  AssertionError: assert b'x89PNG' == b'\\x89PNG'

Replaced the printf invocation with `python3 -c "import sys;
sys.stdout.buffer.write(b'\\x89PNG')"`. Python's sys.stdout.buffer.write
is portable across all POSIX platforms and produces the exact bytes
regardless of shell or printf variant. Test still exercises the same
_run_binary contract (raw bytes through, no decoding).

Skipped on Windows since /usr/bin/env is not present and a Windows
variant of this test would not add coverage beyond what the existing
mocked tests provide.

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 Ready for QA Dev work complete — QA can begin review and removed CI Failed CI failed — dev needs to fix Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 9, 2026
Codecov on the prior commit reported 15.5% patch coverage on the new
clipboard_win32.py because:

- The Linux CI cell can't import pywin32 -- the wrapper's calls
  fail at the import-guard.
- The integration tests in tests/test_clipboard_win32_integration.py
  module-skip on non-Windows so they contribute zero coverage on
  the Linux upload that codecov ingests.

Adds tests/test_clipboard_win32.py with 25 unit tests that inject a
MagicMock as sys.modules['win32clipboard'] before importing the
wrapper, so every public function and helper exercises a real code
path on Linux. Standard format constants (CF_UNICODETEXT, CF_DIB,
etc.) are pre-populated on the mock so _format_id_for_mime and
_format_name's standard-formats dict lookup work without real
pywin32.

Coverage:
  - _import_win32clipboard: cache hit (returns module) + cache miss
    (raises ImportError with pywin32 message)
  - _register_format: cache hit + multi-name cache misses asserting
    one RegisterClipboardFormat call per unique name
  - _open_clipboard_with_retry: success on first attempt + success
    after transient failures + retry-budget exhaustion raising
    RuntimeError with last error
  - _format_id_for_mime: text/plain -> CF_UNICODETEXT (no register
    call), three registered MIMEs -> their conventional Win32 names,
    unsupported MIME -> ValueError
  - read_text: absent format -> empty string + CloseClipboard,
    str return for CF_UNICODETEXT, UTF-8 bytes decoded for
    registered formats, CloseClipboard runs on exception
  - write_text: text/plain passes str unchanged, registered format
    encodes UTF-8 bytes, CloseClipboard runs on exception
  - write_multi: empty input no-op (no OpenClipboard), atomic
    write of multiple formats in one transaction, encoding
    happens before OpenClipboard so encoding errors don't leave
    the clipboard half-open
  - list_formats: EnumClipboardFormats walk + GetClipboardFormatName
    resolution + 'Format<n>' fallback for unknown constants,
    CloseClipboard runs on EnumClipboardFormats exception

Local coverage on clipboard_win32.py: 99% (1 missed line, in the
fallback name-stringification branch). Total project coverage 98%.

Doesn't change the integration tests in test_clipboard_win32_integration.py,
which still cover the real-Windows-clipboard round-trips on the
windows-latest runner (they're the source of truth for behavior; the
unit tests here are the line-coverage source for the codecov gate).

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 Ready for QA Dev work complete — QA can begin review and removed Ready for QA Dev work complete — QA can begin review Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 9, 2026
Closes the three remaining uncovered lines this PR introduced or
moved:

  - clipboard_win32.py:215 -- the str(data) defensive fallback in
    read_text for when GetClipboardData returns something that is
    neither str nor bytes nor bytearray (pywin32 has historically
    returned memoryview / int for some custom formats). New test
    test_read_text_str_fallback_for_unexpected_return_type stubs
    GetClipboardData -> memoryview(b"raw") and asserts read_text
    returns a str, exercising the final return.

  - clipboard.py:208-209 -- the OSError handler in
    _find_wayland_display when iterdir() on the runtime directory
    raises (typically permission denied). New test
    test_find_wayland_display_returns_none_on_iterdir_oserror
    patches Path.iterdir to raise OSError and asserts the function
    returns None. Linux-only so it pairs cleanly with the rest of
    the Wayland-helper tests.

  - clipboard.py:237 -- _wayland_env's "runtime dir does not exist"
    early return. New test
    test_wayland_env_returns_none_when_runtime_dir_is_missing
    points XDG_RUNTIME_DIR at a non-existent path with WAYLAND_DISPLAY
    cleared so the both-set early return at the top of _wayland_env
    does not short-circuit, then asserts None.

After: clipboard.py 100%, clipboard_win32.py 100%. Total project
coverage 99% (16 lines remaining are pre-existing gaps in parser.py
and server.py from before this branch -- out of scope for the
Windows-backend refactor).

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 Ready for QA Dev work complete — QA can begin review and removed Ready for QA Dev work complete — QA can begin review Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 9, 2026
@cmeans cmeans added QA Active QA is actively reviewing; Dev should not push changes and removed Ready for QA Dev work complete — QA can begin review labels May 9, 2026

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

QA Round 1 — QA Failed

Reviewed at head 8d0c7d4. Architectural shift is well-motivated by the post-#145 verification (mc-005 / mc-009 / mc-020 persisting / regressing despite the SetDataObject retry patch — clean diagnostic that the writer-retry shape was treating the wrong defect). The pywin32 backend, the cross-platform CI matrix, and the integration-windows job are all set up cleanly. Two substantive doc/CHANGELOG drift items plus two small code observations.

What's clean

  • src/mcp_clipboard/clipboard_win32.py — synchronous Win32 wrapper, deferred pywin32 import keeps the module parsable on non-Windows hosts (Linux CI parses it without erroring at collection), public API is small and stable (read_text, write_text, write_multi, list_formats), _format_id_cache keeps RegisterClipboardFormat calls bounded, every clipboard op is wrapped in OpenClipboard / Empty/Set/Get / CloseClipboard with try/finally. CF_UNICODETEXT for text/plain (UTF-16 native, no codepage in the path), registered custom format strings ("HTML Format", "Rich Text Format", "image/svg+xml") for the typed paths.
  • src/mcp_clipboard/clipboard.py_windows_read, _windows_list_formats, _windows_write, _windows_write_typed, _windows_write_multi all become thin wrappers dispatching to clipboard_win32.* via asyncio.to_thread, with consistent ClipboardError wrapping. The image read/write paths are explicitly retained on PowerShell with a clear "Phase 2" call-out (acceptable scope choice — DIB ↔ PNG conversion is enough to deserve its own PR). The Windows-safety changes to _find_wayland_display / _wayland_env (os.getuid via getattr fallback) are good hygiene now that pytest runs on windows-latest.
  • pyproject.tomlpywin32>=300; sys_platform == 'win32' with a comment explaining why the marker matters. Linux/macOS installs don't pull the Windows wheel.
  • .github/workflows/ci.ymltest job now ${{ matrix.os }} × 3 Python with fail-fast: false; codecov upload correctly scoped to ubuntu-latest && 3.13. New integration-windows job runs tests/test_clipboard_win32_integration.py -m integration on windows-latest. Per-push Windows coverage replaces the QEMU manual loop.
  • tests/test_clipboard_win32_integration.py — 16 tests; uses pytest.skip(allow_module_level=True) if not Windows, then @pytest.mark.integration so default pytest runs skip the file even on Windows unless -m integration is passed. Round-trip coverage hits the codepoints (em dash + curly quotes + ellipsis, CJK + Arabic + emoji) that the prior backend was lossy on.
  • tests/test_clipboard_win32.py — 408 lines, 100% coverage on the wrapper per Dev's commit titles.
  • tests/test_server.py — ~15 PowerShell-shape tests deleted (e.g. [Console]::InputEncoding present, SetDataObject 4-arg retry); ~13 dispatch-layer tests added (_dispatches_to_win32_wrapper, _wraps_win32_errors_as_clipboard_error). Net test count up: 650 passed, 1 skipped, 19 deselected, 5 xfailed in 3.85s locally (the 1 skip is the integration module itself; the 16 tests inside report as a single module-skip on Linux).
  • uv run ruff check src/ tests/ clean. uv run ruff format --check src/ tests/ clean. uv run mypy src/ clean.
  • All 18 actual CI checks green (test (ubuntu/windows/macos × 3.11/3.12/3.13) matrix, integration-windows, integration-x11, lint, typecheck, version-sync, validate-server-json, codecov/patch).
  • gh pr view 146 --json closingIssuesReferences returns [#143] only — clean.
  • Branch is rebased onto post-#144 main.

Findings

F1 — substantive — Doc drift across CLAUDE.md, README.md, SECURITY.md describing Windows backend as "PowerShell". Phase 1 transitions text formats (text/plain, text/html, text/rtf, image/svg+xml) to pywin32; PowerShell is retained for image read/write only. Three sites stale, one borderline:

  • CLAUDE.md:58 — "Windows PowerShell" in the architecture bullet for clipboard.py. Update to reflect pywin32-via-clipboard_win32 for text + PowerShell for image (Phase 2 follow-up).
  • README.md:346### How It Works step 3 lists "PowerShell Set-Clipboard" as the Windows write command. Stale for text writes (now in-process pywin32).
  • SECURITY.md:61 — "(wl-paste, xclip, pbpaste, osascript, PowerShell)" — partially stale. Mention pywin32 for the in-process paths; PowerShell still applies to image read/write.
  • README.md:123 (borderline) — | Windows | Built-in | No install needed (PowerShell) |. With pywin32 added as a Windows-only dependencies entry, pip auto-installs it, so "no install needed" remains user-true. Optional.

Per feedback_doc_drift_is_substantive.md and the project's prior pattern (every prior tool-add PR updated CLAUDE.md), fix in this PR cycle.

F2 — substantive — Duplicate ### Added blocks in CHANGELOG.md under ## [Unreleased]. CHANGELOG.md:7-13 is the existing ### Added block from #141 (clipboard_version tool). CHANGELOG.md:15-27 is a NEW ### Added block with this PR's "Cross-platform CI matrix" and "integration-windows CI job" bullets. Per Keep-a-Changelog (and CLAUDE.md's "Adding a CHANGELOG entry on every PR" section), each category appears at most once per release. Merge the two — append the new bullets under the existing ### Added heading, then keep the new ### Changed and the existing ### Fixed sections in their canonical Keep-a-Changelog order.

F3 — observation — silent except Exception: pass in _format_name. src/mcp_clipboard/clipboard_win32.py:344-348:

try:
    name = str(win32clipboard.GetClipboardFormatName(fmt))
    if name:
        return name
except Exception:
    # GetClipboardFormatName raises for built-in formats it does not know;
    # fall through to the numeric stringification.
    pass

Per feedback_silent_exceptions.md, bare except: pass patterns should at minimum log so a future debugging session can find why a format ID couldn't be resolved. Suggest logger.debug("GetClipboardFormatName(%d) failed: %s", fmt, exc) before falling through. Trivial; preserves the fall-through semantics.

F4 — observation — read_text has a comment that doesn't match the code at src/mcp_clipboard/clipboard_win32.py:213-215:

# Defensive: pywin32 has historically returned bytes-with-null-terminator
# for some custom formats. Strip a single trailing NUL if present.
return str(data)

The comment promises NUL stripping; the code is return str(data) (no stripping, and str() on a non-str/non-bytes object is a poor fallback for clipboard data anyway). Either implement the NUL strip (s = data if isinstance(data, str) else bytes(data).decode("utf-8", "replace"); return s.rstrip("\x00") — but the bytes path is already handled above, so the unreachable path comment is the issue) or remove the comment and tighten the fallback. As written, a future reader will trust the comment and assume the code does what it says.

Heads-up (not findings)

  • PR body's test-plan note "The 2 remaining failures (test_max_*_bytes_non_integer_raises_at_import) are pre-existing environment issues..." doesn't reproduce in this QA session — both tests pass cleanly under uv run pytest -v against this branch's .venv. Likely a Dev-side miseuv interaction; not a PR concern. Maintainer may want to confirm before merge if the claim affects their merge confidence.
  • After merge, the Phase 2 follow-up (porting _windows_read_image / _windows_write_image to pywin32 with DIB ↔ PNG conversion) should get a tracking issue if one doesn't already exist — the CHANGELOG ### Changed entry mentions it as "(a follow-up PR)" but that's a PR-body assertion, not a discoverable tracker. Suggest filing the issue before signoff.
  • Next release-PR aggregates Unreleased: ### Added (clipboard_version + CI matrix + integration-windows job) + ### Changed (Windows backend rewrite) + ### Fixed entries from #144 / earlier ### Fixed entries — highest-category-wins → minor → v2.7.0, not v2.6.2 as some prior PR bodies still say.

Verdict

QA Failed — F1 and F2 must land in this PR (CLAUDE.md/README/SECURITY drift, CHANGELOG duplicate ### Added). F3 and F4 either fix or push back with rationale. Architecture is right; details need tightening. Expect Round 2 to be straightforward.

@cmeans

cmeans commented May 9, 2026

Copy link
Copy Markdown
Owner

Audit: applying QA Failed per Round 1 review above. Two substantive (doc drift across CLAUDE.md/README/SECURITY; duplicate ### Added in CHANGELOG) plus two observations (silent except in _format_name; mismatched NUL-strip comment in read_text). Architectural shift itself is well-motivated and clean.

@cmeans cmeans added the QA Failed QA found issues — needs dev attention label May 9, 2026
@cmeans-claude-dev cmeans-claude-dev Bot added the Ready for QA Dev work complete — QA can begin review label May 9, 2026
@github-actions github-actions Bot removed the Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA label May 9, 2026
clipboard.py dispatches the sync Win32 path through asyncio.to_thread, so
two concurrent first-callers into _get_clipboard_hwnd could each race past
the None-check and call CreateWindowEx, stranding one HWND per burst.
Adds a module-level threading.Lock and double-checked locking around the
lazy creation. Cached-path read stays lock-free.

Also adds test_get_clipboard_hwnd_is_thread_safe (32 concurrent first-
callers via threading.Barrier; asserts CreateWindowEx fires exactly once
and all callers see the same cached HWND).

Addresses Round 4 heads-up; total tests now 654.

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 Ready for QA Dev work complete — QA can begin review labels May 9, 2026
@cmeans-claude-dev

Copy link
Copy Markdown
Contributor Author

Round 5 addendum — heads-up items addressed

After re-reading the R4 heads-up section, decided to close all three rather than defer:

Lazy _owner_hwnd race — fixed at 6d306c7. Added threading.Lock + double-checked locking around the lazy creation in _get_clipboard_hwnd. Cached-path read stays lock-free (the if _owner_hwnd is not None: return happens before the lock acquire). New regression test test_get_clipboard_hwnd_is_thread_safe runs 32 concurrent first-callers via threading.Barrier and asserts CreateWindowEx fires exactly once and every caller sees the same cached HWND.

PR-body test count (620 → 654) and pre-existing-failures claim — fixed in PR description. Updated the test-plan line to reflect the actual count and removed the test_max_*_bytes_non_integer_raises_at_import "pre-existing failures" caveat that nobody can reproduce. Verified locally: both tests pass cleanly on 6d306c7 (and on main). The original Dev-side claim came from a mise-vs-uv sys.executable quirk in the original commit env that isn't a property of this branch; treating it as a stale local artifact rather than a documented PR fact. Boxes unchecked per the project rule that QA ticks them, not Dev.

Verification on 6d306c7

uv run pytest -q654 passed, 1 skipped, 19 deselected, 5 xfailed in 4.06s (+1 over R5's 653 from the new thread-safety test). uv run ruff check, uv run ruff format --check, uv run mypy src/ all clean.

@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 9, 2026
The 25b2b49 process-owned message-only window fix is necessary but not
sufficient: live CD-Windows QA on PR #146 showed raw SetClipboardData
for registered custom formats still silently no-ops when the prior
clipboard owner is a foreign process. mc-005 / mc-017 / mc-020 / mc-028 /
mc-201 fail with the call returning success but list_formats afterward
showing the prior state surviving. mc-303 confirmed timing-dependence
(6 SVG-write failures then 1 success on identical args).

MSDN is explicit on this: "Sharing non-standard clipboard data formats
between processes requires using the OleSetClipboard API, as
SetClipboardData alone is not enough." OleSetClipboard hands the
clipboard to an OLE-managed internal window handle that handles
foreign-owner transitions correctly -- the same path .NET's
Clipboard.SetDataObject and PowerShell's Set-Clipboard take.

This commit:
- Adds _ClipboardDataObject implementing IDataObject (GetData,
  QueryGetData, EnumFormatEtc + E_NOTIMPL stubs for the rest) via
  pywin32's win32com.server.util.wrap and pythoncom.IID_IDataObject.
- Adds _ensure_com_init for lazy STA CoInitializeEx per asyncio
  worker thread (caches in threading.local).
- Adds _write_via_ole that publishes the IDataObject via
  OleSetClipboard and immediately OleFlushClipboard's, releasing
  our object so paste targets get HGLOBAL handles without needing
  our process to pump messages.
- Adds _encode_for_format to handle CF_UNICODETEXT's UTF-16 LE +
  NUL contract under HGLOBAL bytes (the raw-SetClipboardData str
  overload's null-termination is no longer in the path).
- Adds _ole_set_clipboard_with_retry mirroring _open_clipboard_with_retry's
  10 x 50ms retry budget against CLIPBRD_E_CANT_OPEN.
- Refactors write_text / write_multi to call _write_via_ole.
- Reads stay on the existing OpenClipboard + GetClipboardData path.

Test surface: the unit tests run on Linux CI by injecting MagicMocks
for win32clipboard, win32gui, win32con, pythoncom, winerror,
win32com.server.util, and win32com.server.exception via sys.modules.
24 new tests cover _ensure_com_init, _encode_for_format, the IDataObject
methods, _make_data_object, _ole_set_clipboard_with_retry, and the new
write_text / write_multi OLE call shapes. Total tests: 676 (up from 654).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cmeans-claude-dev

Copy link
Copy Markdown
Contributor Author

Round 5 — switch write path to OleSetClipboard + OleFlushClipboard

Pushed at d3d7372. The QA-failed CD-Windows run on 6d306c7 confirmed the message-only window fix from 25b2b49 is necessary but not sufficient: registered custom formats (image/svg+xml, HTML Format) still silently no-op when the prior clipboard owner is a foreign process. mc-303 (the SVG repeat-probe) showed it as timing-dependent — 6 failures then 1 success on identical wire-level calls — not a deterministic bug.

Root cause and fix

MSDN is explicit on this case:

"Sharing non-standard clipboard data formats between processes requires using the OleSetClipboard API, as SetClipboardData alone is not enough."

Raw SetClipboardData puts only a hglobal blob on the clipboard — no FORMATETC metadata, no DVASPECT, no TYMED — so paste targets that consult the OLE clipboard chain (Chrome, Office, CD's chat renderer) see incomplete offerings. Worse, SetClipboardData for custom formats has a documented silent-failure mode where GetLastError returns zero and no exception is thrown. OleSetClipboard hands the clipboard to OLE's internal window handle — the same path .NET's Clipboard.SetDataObject and PowerShell's Set-Clipboard take, and the only documented way to land registered custom formats reliably across processes.

What the commit does

  • Adds _ClipboardDataObject implementing IDataObject (GetData / QueryGetData / EnumFormatEtc + E_NOTIMPL stubs for the rest) via win32com.server.util.wrap and pythoncom.IID_IDataObject. Mirrors the canonical pattern from mhammond/pywin32/com/win32com/test/testClipboard.py.
  • Adds _ensure_com_init for lazy STA CoInitializeEx per asyncio.to_thread worker (cached in threading.local). asyncio workers don't get pythoncom's main-thread auto-init.
  • Adds _write_via_ole which publishes the IDataObject via OleSetClipboard then immediately OleFlushClipboards. The flush renders all formats into HGLOBAL handles and releases our data-object pointer — the clipboard persists without our process pumping messages.
  • Adds _encode_for_format so CF_UNICODETEXT gets the UTF-16 LE + NUL contract under HGLOBAL bytes (the raw-SetClipboardData str overload's null-termination is no longer in the path).
  • Adds _ole_set_clipboard_with_retry mirroring the existing _open_clipboard_with_retry (10 × 50ms against CLIPBRD_E_CANT_OPEN).
  • Refactors write_text / write_multi to call _write_via_ole. Reads stay on the existing OpenClipboard + GetClipboardData path.

Verification

uv run pytest -q676 passed, 1 skipped, 19 deselected, 5 xfailed (+22 over R5's 654 from new tests covering _ensure_com_init, _encode_for_format, _ClipboardDataObject methods, _make_data_object, _ole_set_clipboard_with_retry, and the new OLE write call shapes). uv run ruff check, uv run ruff format --check, uv run mypy src/ all clean.

Carry-forward for QA

  • mc-006 / mc-007 / mc-016 / mc-202 / mc-203 (PNG/JPEG copy_image not registering image/png / image/jpeg as custom format names) are NOT addressed by this commit — the image path is still on PowerShell pending Phase 2 / Phase 2: port _windows_read_image and _windows_write_image to pywin32 #147. CD chat-render of pasted SVG as fenced markup (mc-303 second finding) is a CD chat-rendering gap, not a server-side issue.
  • mc-204 (text/html silent no-op after a prior image-DIB write) and the SVG silent-no-op cases (mc-005 / mc-017 / mc-020 / mc-028 / mc-201) are the failure modes this commit targets directly.
  • CI integration-windows job will exercise the new OLE path on windows-latest; live QEMU re-verification under CD is what closes the loop on the foreign-prior-owner race.

Ready for re-QA at the new HEAD.

The integration-windows CI on d3d7372 failed every test with
"CoInitialize has not been called" from OleSetClipboard, despite
_ensure_com_init() running first. Root cause: pywin32's main-thread
auto-init is fragile in uv-installed venvs (no pywin32 post-install
step). Our threading.local-cached _com_state.initialized flag flipped
to True after the first call but the underlying Win32 apartment was
never actually set up, so subsequent calls skipped the init too.

Drop the cache. Call pythoncom.CoInitializeEx(COINIT_APARTMENTTHREADED)
unconditionally at the start of every OLE op. CoInitializeEx is
idempotent on same-mode re-init (returns S_FALSE without raising), so
the proactive call costs only one cheap COM check on the steady-state
path. Catch RPC_E_CHANGED_MODE silently — the thread was previously
inited to a different model and we can't change it. Any other HRESULT
is surfaced.

Replaces test_ensure_com_init_calls_coinitializeex_once_per_thread and
test_ensure_com_init_is_per_thread (cache-aware) with three new tests:
- _calls_coinitializeex_every_call (asserts no caching)
- _swallows_rpc_e_changed_mode
- _propagates_other_errors

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

New commits pushed while QA was active. QA review invalidated — resetting to Awaiting CI.

cmeans-claude-dev Bot and others added 4 commits May 11, 2026 10:23
The integration-windows CI on both d3d7372 (cached _ensure_com_init) and
3904fef (uncached, always-call) failed identically:

  OleSetClipboard failed after 10 retries; Last error:
  com_error(-2147221008, 'CoInitialize has not been called.', None, None)

pythoncom.CoInitializeEx was being called proactively yet OleSetClipboard
still saw the apartment as uninitialized -- the pywin32 wrapper silently
no-ops in uv-installed venvs where pywin32_postinstall has not run, the
configuration the windows-latest GitHub Actions runner ships with.

Switch to a ctypes-direct call: ole32 = ctypes.windll.ole32;
ole32.CoInitializeEx(None, COINIT_APARTMENTTHREADED). Goes straight to
ole32.dll regardless of pywin32 bootstrap state. Return-value handling
keeps S_OK and S_FALSE as success, accepts RPC_E_CHANGED_MODE silently,
and raises OSError on any other FAILED HRESULT.

Tests now mock ctypes.windll.ole32 via the fixture (ctypes.windll only
exists on Windows; the fake is removed on teardown). Three tests cover
the success / accepted-error / propagated-error paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The integration-windows CI on d3d7372, 3904fef, and 0a0f933 all failed
identically with OleSetClipboard raising CO_E_NOTINITIALIZED ("CoInitialize
has not been called") on every test, regardless of init strategy:

  d3d7372: pythoncom.CoInitializeEx, cached per thread
  3904fef: pythoncom.CoInitializeEx, no cache (every call)
  0a0f933: ctypes ole32.CoInitializeEx, no cache (every call)

The apartment was reporting success on init and then unavailable on the
next line. The root cause appears to be that pythoncom in uv-installed
venvs (no pywin32_postinstall step) does not reliably keep the calling
thread's apartment stable for OLE clipboard ops, even when CoInitializeEx
returns S_OK.

Resolution: move OLE writes onto a dedicated daemon worker thread that
inits its apartment once at thread start (via ctypes ole32.CoInitializeEx)
and services a queue of submitted callables via concurrent.futures.Future.
The worker is lazy-started on first _write_via_ole call, with
double-checked locking against concurrent first-callers from asyncio.
Reads stay on the existing OpenClipboard + GetClipboardData path; OLE
is only required for the cross-process write race.

Tests:
- 9 new tests cover _ClipboardWorker (init success / init failure / queue
  exception path), _get_clipboard_worker (lazy start + double-checked
  thread-safety with 16 racing first-callers), and the new
  _ole_write_on_worker helper.
- 4 coverage-gap tests added for previously-missed branches:
  _get_clipboard_hwnd's inner-lock cached-HWND return (simulated race),
  QueryGetData wrong-aspect (DV_E_DVASPECT) and wrong-tymed (DV_E_TYMED),
  and _write_via_ole's empty-payloads early return.
- clipboard_win32.py patch coverage now 100% (was 98.02%, 4 missing
  lines).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The MSDN OleSetClipboard docs are explicit and we missed it:

  "Before calling this function, you must initialize the OLE library
   by calling OleInitialize."

CoInitializeEx alone is necessary but not sufficient for OLE clipboard
ops. It sets up the COM apartment but does not enable the OLE-specific
subsystems (clipboard, drag-and-drop, marshaling tables) that
OleSetClipboard / OleFlushClipboard need. The CO_E_NOTINITIALIZED error
from OleSetClipboard on commits d3d7372 / 3904fef / 0a0f933 / 0966ef7
was OLE reporting its OLE-specific state as uninitialized even though
the underlying COM apartment was live -- which is why no amount of
re-calling CoInitializeEx (per-call, ctypes-direct, dedicated worker
thread) ever cleared it.

Switch the worker-thread init from ole32.CoInitializeEx(NULL, COINIT_*)
to ole32.OleInitialize(NULL). OleInitialize internally calls
CoInitializeEx with COINIT_APARTMENTTHREADED plus the OLE subsystem
setup. Single-argument ctypes signature, same accept-set
(S_OK / S_FALSE / RPC_E_CHANGED_MODE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
OleInitialize on 4ff184a unblocked the OLE clipboard path -- text/plain,
text/html, and the bulk of the integration tests pass. Three RTF / SVG
round-trip tests still fail with a different signature:

  AssertionError: assert '...{\b world}!}\x00' == '...{\b world}!}'

pywin32's STGMEDIUM.set with TYMED_HGLOBAL allocates GlobalAlloc(len + 1)
and appends a trailing NUL byte to the payload (internal C convention
for null-terminated buffers). The OLE clipboard then returns the full
HGLOBAL contents on read, NUL byte included. text/plain isn't affected
because CF_UNICODETEXT reads come back as a Python str (pywin32 strips
the wchar NUL). text/html isn't affected because the test checks 'CF_HTML
wrapper present', not exact equality. SVG / RTF tests do assert exact
equality and trip on the trailing \x00.

Fix in read_text: if the bytes payload ends with b"\x00", drop it before
decoding. Single test added that asserts both shapes (with NUL,
without NUL) round-trip cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

QA Round 5 — QA Failed

Reviewed at head 8535045. Six new commits since R4 walk through a substantial COM-init debugging cycle. R4's F1 docstring nit fixed; R4's owner_hwnd race heads-up closed; the OLE pivot landed; CI integration-windows (which was FAILURE at the time R5 was first started, before user interrupt) is now SUCCESS. One residual doc drift item — the CLAUDE.md / README descriptions of the write API didn't track the OLE pivot.

Round 4 finding + heads-up — status

Item Resolution
F1 nit — stale "(the desktop window)" parenthetical at clipboard_win32.py:181-184 Fixed in c8daba0. Docstring now matches the message-only-window implementation.
Heads-up — possible race on lazy _owner_hwnd creation Closed in 6d306c7. Added module-level threading.Lock with double-checked locking around the lazy creation; cached-path read stays lock-free. New test_get_clipboard_hwnd_is_thread_safe uses 32 concurrent first-callers via threading.Barrier to assert exactly one CreateWindowEx call.

What happened between R4 and R5 — the COM-init debugging cycle

The CI run on d3d7372 (the first OLE pivot) showed all 13 integration-windows tests failing identically with:

com_error(-2147221008, 'CoInitialize has not been called.', None, None)
RuntimeError: OleSetClipboard failed after 10 retries

Five iterations to land the right fix:

Commit Strategy Outcome
d3d7372 pythoncom.CoInitializeEx, cached per-thread via threading.local CI failed — apartment reported live but OleSetClipboard saw it as uninitialized
3904fef Removed the cache, call CoInitializeEx proactively every call Same failure (cache wasn't the problem)
0a0f933 Bypassed pywin32, called ole32.CoInitializeEx via ctypes Same failure (pywin32 wasn't the problem either)
0966ef7 Moved OLE writes to a dedicated daemon worker thread that inits its apartment once at startup, services a queue of Futures Same failure (per-call init wasn't the problem)
4ff184a Switched the worker init from CoInitializeEx to OleInitialize Resolved. MSDN: "Before calling [OleSetClipboard], you must initialize the OLE library by calling OleInitialize." CoInitializeEx alone sets up the COM apartment but does not enable the OLE-specific subsystems (clipboard, drag-and-drop, marshaling tables) OleSetClipboard requires.
8535045 Strip a single trailing \x00 from registered-format reads — STGMEDIUM.set with TYMED_HGLOBAL allocates GlobalAlloc(len+1) and appends a NUL terminator, which GetClipboardData returns on read. SVG / RTF exact-equality tests then trip on the trailing byte. Fixed; SVG / RTF round-trip tests now pass.

The final pattern (dedicated daemon thread + OleInitialize + concurrent.futures.Future queue) is sound and matches what OleSetClipboard's caller-contract requires per MSDN.

Round 5 verification

  • uv run pytest -q686 passed, 1 skipped, 19 deselected, 5 xfailed in 3.99s (+33 over R4's 653 from the OLE iteration's accumulated test churn: COM-init coverage, worker-thread coverage, NUL-strip coverage, and the four coverage-gap tests added in 0966ef7).
  • uv run ruff check src/ tests/ clean. uv run ruff format --check src/ tests/ clean. uv run mypy src/ clean.
  • gh pr view 146 --json statusCheckRollup — 18 actual CheckRuns SUCCESS, 3 SKIPPED. integration-windows is SUCCESS on 8535045. (Earlier on d3d7372/3904fef/0a0f933/0966ef7 it was FAILURE; the trail is in the workflow runs for anyone auditing.)
  • clipboard_win32.py patch coverage 100% per 0966ef7's commit message.
  • gh pr view 146 --json closingIssuesReferences returns [#143].

Findings

F1 — substantive — Doc drift across CLAUDE.md and README, again. After the OLE pivot (d3d7372 + 4ff184a), the Windows write path goes through OleSetClipboard + OleFlushClipboard on a dedicated COM worker thread (_ClipboardWorker), not raw SetClipboardData. Reads still go through OpenClipboard + GetClipboardData on the calling thread. The R2 doc fixes (which described the write side as SetClipboardData) are now stale. Two sites:

  • CLAUDE.md:60clipboard_win32.py architecture bullet currently lists OpenClipboard / EmptyClipboard / SetClipboardData / GetClipboardData / EnumClipboardFormats / RegisterClipboardFormat / CloseClipboard as the API calls used. With the OLE pivot, writes use OleSetClipboard / OleFlushClipboard (and the worker calls OleInitialize once at startup); reads still use OpenClipboard + GetClipboardData. Update to distinguish the two paths and mention the worker thread.
  • README.md:346 — "How It Works" step 3 says On Windows, calls the Win32 clipboard API directly via pywin32 (\SetClipboardData` under `CF_UNICODETEXT` for text/plain; under registered `HTML Format` / `Rich Text Format` / `image/svg+xml` for typed content). The format-mapping is right; the API name is wrong post-pivot. Either rephrase to mention OleSetClipboardfor writes or describe the architecture as "publishes a PythonIDataObjectviapythoncom.OleSetClipboardon a dedicated COM worker thread; formats render asCF_UNICODETEXT/HTML Format/Rich Text Format/image/svg+xml`."

The CHANGELOG already has the correct narrative (the second ### Changed bullet at CHANGELOG.md:53-84 walks through the OLE pivot in detail). The CLAUDE.md / README updates can mirror that bullet's language. Per feedback_doc_drift_is_substantive.md — substantive in this PR cycle.

Heads-up (not findings)

  • CHANGELOG internal inconsistency between the two ### Changed bullets: bullet 1 (lines 28-52) says the backend "uses the standard Win32 OpenClipboard / EmptyClipboard / SetClipboardData / GetClipboardData / ... API directly via pywin32." Bullet 2 (lines 53-84) supersedes that with "Windows writes go through OleSetClipboard + OleFlushClipboard instead of raw SetClipboardData". A reader of the merged release notes will see both. Bullet 2 explicitly says "instead of raw SetClipboardData", so the narrative reads correctly as an evolution — but bullet 1 still misnames the final write API. Optional polish: drop the SetClipboardData mention from bullet 1 or merge the two bullets into one cohesive narrative.
  • PR body test count is now well out of date — checkbox 1 says "620 unit tests pass; 16 integration tests skip on Linux"; actual on 8535045 is 686 passed, 1 module-level skip, 19 deselected, 5 xfailed. Cosmetic; checkbox stays valid.
  • NUL-strip edge case at clipboard_win32.py:485 strips a single trailing \x00 from registered-format reads to undo the HGLOBAL terminator that STGMEDIUM.set adds on the write side. Safe for SVG / HTML / RTF text payloads (which don't legitimately end in NUL); only matters if a future format ever carries genuinely NUL-terminated bytes that must be preserved exactly. Not a finding; just worth noting in case it ever bites.
  • Maintainer-side QEMU re-verification on 8535045 is the final gate before QA Approved. The integration-windows CI job exercises the same clipboard_win32 code path on windows-latest, but the mc-005 / mc-017 / mc-020 cross-process-foreign-owner symptoms that originally drove the OLE pivot were only observed in QEMU + Claude Desktop, not in CI. Worth a sanity round-trip on the QEMU guest before the merge.
  • Phase 2 tracker #147 stays open for _windows_read_image / _windows_write_image port + DIB ↔ PNG conversion.

Verdict

QA Failed — F1 doc drift on CLAUDE.md:60 + README.md:346 to match the OLE-pivoted write path. Otherwise the implementation is sound; the COM-init debugging cycle was thorough and the resulting architecture (dedicated COM worker thread + OleInitialize) is correct. Expect Round 6 to be a two-site docs touch.

@cmeans

cmeans commented May 11, 2026

Copy link
Copy Markdown
Owner

Audit: applying QA Failed per Round 5 review above. R4's F1 docstring nit + owner_hwnd race heads-up both closed cleanly. Six new commits walk through the COM-init debugging cycle (5 attempts before landing on OleInitialize + dedicated worker thread) — diagnostic trail is thorough. integration-windows CI now SUCCESS on 8535045. Single new finding: F1 substantive doc drift on CLAUDE.md:60 + README.md:346 — they still describe writes as raw SetClipboardData but the OLE pivot moved them to OleSetClipboard + OleFlushClipboard on a dedicated COM worker thread.

cmeans-claude-dev Bot and others added 5 commits May 11, 2026 11:47
…lake

PR #146 CC-on-QEMU-Windows e2e run on 8535045 (run-index entry id
a796b55f-f727-43d2-be46-338ffdb99fd9) saw the SVG silent-no-op race
move from deterministic (100% failure pre-OLE) to flaky (4 of 30
entries, all recovered on the agent's manual retry). integration-
windows CI on windows-latest passes cleanly because the runner has no
clipboard chain monitors; real Windows 11 desktops have several
(clipboard manager, OneDrive shell extension, antivirus) which can
briefly re-grab ownership between our OleSetClipboard and the visible
clipboard state and EmptyClipboard our write before it lands.

Server-side mitigation: after OleSetClipboard + OleFlushClipboard, call
IsClipboardFormatAvailable for every format we just published. If any
is missing, sleep 50ms and retry the publish. Budget of 3 attempts;
worst-case overhead 150ms on the bad path. Happy path pays only one
IsClipboardFormatAvailable call per format (no sleep on success).

If all 3 attempts fail to land the format, raise RuntimeError naming
the missing format IDs -- callers get a diagnosable error instead of
a silent no-op.

The IDataObject is rebuilt for each retry because OleSetClipboard
AddRef's and OleFlushClipboard releases the wrapped pointer; reusing
the same wrapped object across publish cycles is not documented to be
safe.

Four new tests cover: happy path (verify passes first attempt, no
retry), recovery on transient miss (first attempt sees format absent,
second sees it present, no error), budget exhaustion (RuntimeError
naming the missing format IDs), and multi-format verification
(every published format is checked).

Coverage on clipboard_win32.py stays at 100%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #146 CC-on-QEMU-Windows e2e run on 3078f35 (run-index entry
874f183d-ea67-4e3c-85f6-11cf70ef32c9) confirmed the verify-retry
architecture works -- it caught several SVG races -- but the 3-attempt
x 50ms budget (150ms total) wasn't always enough. mc-017 and mc-020
(SVG) and mc-009 (text/html) all still flaked, recovering on the
agent's manual retry seconds later. The race isn't SVG-specific: any
registered custom format is affected when a chain listener
(clipboard manager / OneDrive / antivirus) takes longer than 150ms
to release ownership.

Bump retries 3 -> 5 and switch to an exponential-ish per-attempt
delay schedule: 50, 100, 200, 400ms (750ms total sleep across the
four retry waits). Combined with ~50ms per attempt of work, worst-
case bad-path latency is ~1s. Happy path stays microseconds (single
IsClipboardFormatAvailable per format, no sleep).

The schedule lives in a module-level tuple
(_OLE_WRITE_VERIFY_DELAY_SCHEDULE_MS) so future tuning doesn't
require touching the loop body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
QEMU run on ec6d6a5 (5 attempts x exponential 50/100/200/400ms)
closed mc-020 race-bucket (3/3 PASS) but not mc-017 race-bucket
(1/3 PASS): a chain listener stayed active across the entire
fixed budget on the DIB-residue prior state from mc-016.

Replace the fixed-sleep schedule with a quiescence wait keyed on
GetClipboardSequenceNumber. The kernel-maintained DWORD advances
on every system-wide clipboard mutation, so after a failed
IsClipboardFormatAvailable verify the wrapper now polls the
sequence number until two consecutive samples match before
retrying. Retry fires when the chain has actually settled,
regardless of which listener was active or how long it took.

Budget cap unchanged (worst case ~1s); happy path stays
microseconds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #146 e2e flake across commits 8535045 / 3078f35 / ec6d6a5 / 6837c36
was not a contention race; it was the consequence of running
OleSetClipboard on a worker thread with no message pump.

OleSetClipboard hands clipboard ownership to a hidden CLIPBRDWNDCLASS
window that lives on the calling thread. Consumers (clipboard
managers, OneDrive, antivirus) issue WM_RENDERFORMAT against that
window to materialize our formats. With no pump on the worker, those
messages queue forever; per Old New Thing the system waits up to 30s
for delay-rendered data before consumers give up and synthesize their
own clipboard copy. That synthesis presents to us as the silent-no-op
symptom that no retry budget could close.

Replace with the canonical Win32 raw-SetClipboardData pattern used by
Chromium (`ui/base/clipboard/clipboard_win.cc`), pyperclip, and pyclip:

  OpenClipboard(owner_hwnd) -> EmptyClipboard() ->
    for each (format_id, payload):
      h = GlobalAlloc(GMEM_MOVEABLE, len(payload))
      memcpy(GlobalLock(h), payload, len(payload)); GlobalUnlock(h)
      SetClipboardData(format_id, h)   # system takes ownership
  -> CloseClipboard()

No OLE, no IDataObject, no hidden window, no delayed rendering, no
message pump dependency, no post-write verify, no retry on
SetClipboardData itself. GMEM_MOVEABLE is explicit because GMEM_FIXED
handles are silently rejected per MSDN.

Net diff: ~400 lines removed (_ClipboardDataObject, _ClipboardWorker,
_ole32_initialize, _ole_set_clipboard_with_retry, _ole_write_on_worker,
_get_clipboard_sequence_number, _wait_for_clipboard_quiescent,
the 5-attempt verify-retry loop). 670 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

QA Round 6 — QA Failed

Reviewed at head 4ca7a59. Five new commits walk through one more diagnostic loop (read-back verify → exponential backoff → sequence-number quiescence wait) before discovering the OLE pivot itself was the wrong call and rolling it back entirely. R5's F1 doc drift is resolved as a side-effect of the rollback — the docs that described SetClipboardData for writes are accurate again. One new substantive finding on CHANGELOG narrative — three Unreleased bullets now describe the dev journey rather than the final state.

Round 5 finding — status

R5 finding Resolution
F1 substantive — Doc drift on CLAUDE.md:60 + README.md:346 (described writes as SetClipboardData while the implementation used OleSetClipboard) Resolved by 4ca7a59 rolling the implementation back to raw SetClipboardData rather than by updating the docs. Both files now accurately describe the final state — CLAUDE.md:60 lists the same Win32 API calls the new write path uses (OpenClipboard / EmptyClipboard / SetClipboardData / GetClipboardData / EnumClipboardFormats / RegisterClipboardFormat / CloseClipboard); README.md:346 says "On Windows, calls the Win32 clipboard API directly via pywin32 (SetClipboardData under CF_UNICODETEXT for text/plain; under registered HTML Format / Rich Text Format / image/svg+xml for typed content)".

What happened between R5 and R6 — the OLE-rollback cycle

R5 ended with the OLE-pivot + COM-worker + OleInitialize pattern passing CI. CD-Windows e2e then surfaced a flaky residual silent-no-op (4 of 30 entries, all recovered on the agent's manual retry). The team iterated:

Commit Strategy Outcome
3078f35 Read-back verify after OleSetClipboard + retry-publish loop, 3 attempts × 50ms Closed some races but not all
ec6d6a5 Bump verify-retry to 5 attempts × exponential 50/100/200/400 ms (~1s budget) Closed mc-020 race-bucket; mc-017 still flaked
2d72012 Replace fixed sleep with GetClipboardSequenceNumber quiescence wait (poll until two consecutive samples match) Still flaked under certain chain-listener patterns
6837c36 ruff format pass n/a
4ca7a59 Abandon OLE entirely. Switch to raw SetClipboardData with explicit GlobalAlloc(GMEM_MOVEABLE) + GlobalLock + memcpy + GlobalUnlock HGLOBAL pattern. Matches what Chromium (ui/base/clipboard/clipboard_win.cc), pyperclip, and pyclip use. Resolved.

The root-cause analysis in 4ca7a59's commit message and the new module docstring at src/mcp_clipboard/clipboard_win32.py:27-71 is excellent: OleSetClipboard creates a hidden CLIPBRDWNDCLASS window on the calling thread for WM_RENDERFORMAT / WM_DESTROYCLIPBOARD dispatch. With no message pump on the worker thread, those messages queue forever; chain listeners (clipboard managers, OneDrive, antivirus) hit the documented 30-second WM_RENDERFORMAT timeout and synthesize their own clipboard snapshot, which presents to us as our write being silently overwritten. The R5-era OLE pivot was treating a different bug (the lazy-init HWND-creation race that was actually closed by 6d306c7's double-checked lock), and once that race was fixed, raw SetClipboardData with the message-only window owner from 25b2b49 was sufficient. GMEM_MOVEABLE is explicit because SetClipboardData silently rejects GMEM_FIXED handles.

Net diff vs 8535045: clipboard_win32.py shrinks by 222 lines, test_clipboard_win32.py shrinks by 166 lines. _ClipboardDataObject, _ClipboardWorker, _ole32_initialize, _ole_set_clipboard_with_retry, _ole_write_on_worker, _get_clipboard_sequence_number, _wait_for_clipboard_quiescent, and the 5-attempt verify-retry loop all gone. The PR is simpler than it was three rounds ago.

Round 6 verification

  • uv run pytest -q670 passed, 1 skipped, 19 deselected, 5 xfailed in 3.99s (commit message claims 670; matches).
  • uv run ruff check src/ tests/ clean. uv run ruff format --check src/ tests/ clean. uv run mypy src/ clean.
  • gh pr view 146 --json statusCheckRollup — 19 actual CheckRuns SUCCESS, 8 SKIPPED, 2 pending (QA Gate StatusContext). integration-windows is SUCCESS on 4ca7a59.
  • gh pr view 146 --json closingIssuesReferences returns [#143].

Findings

F1 — substantive — CHANGELOG narrative describes the dev journey, not the final state. ## [Unreleased] now contains three bullets describing what happened on this PR:

  • CHANGELOG.md:28-52 (### Changed bullet 1) — Backend rewrite to pywin32, says it uses raw Win32 API including SetClipboardData. Accurate post-rollback.
  • CHANGELOG.md:53-84 (### Changed bullet 2) — OLE pivot, says it switched FROM raw SetClipboardData TO OleSetClipboard + OleFlushClipboard on a dedicated COM worker thread. NOT TRUE OF THE SHIPPING STATE (this commit rolled it back).
  • CHANGELOG.md:87-108 (### Fixed new in R6) — Rollback of the OLE path, says it replaced OleSetClipboard with raw SetClipboardData + GlobalAlloc(GMEM_MOVEABLE). Describes a fix to a problem that, from a user / release-notes perspective, never shipped — the OLE path lived entirely within this un-merged PR's commit history.

A reader of the merged release notes will see all three bullets and have to reconcile that the journey through OLE is irrelevant. The project's prior convention (per #138 R6's revert: CHANGELOG describes what shipped, not the journey) argues for consolidating to the single accurate Changed bullet:

  1. Drop bullet 2 from ### Changed (the OLE pivot description).
  2. Drop the new ### Fixed bullet (the OLE rollback description).
  3. Optionally tighten bullet 1 to call out the explicit GMEM_MOVEABLE HGLOBAL handle convention if the project wants to highlight that detail — but the journey through OLE and back should not appear in the user-facing changelog.

The technical content that DOES belong somewhere is the diagnostic chain (why OLE was tried and why it didn't fit the long-lived-server use case). That belongs in the module docstring at src/mcp_clipboard/clipboard_win32.py:43-56 — which is exactly where it currently lives, and it's well-written there. Keep it. The CHANGELOG just needs the consolidation.

Heads-up (not findings)

  • PR body test plan checkbox 1 still says "620 unit tests pass; 16 integration tests skip on Linux; 2 pre-existing failures". Actual on 4ca7a59 is 670 passed, 1 module-level skip (integration file), 19 deselected, 5 xfailed, 0 failures. The pre-existing-failures claim continues not to reproduce in QA env. Cosmetic.
  • Maintainer-side CD-Windows live verification on 4ca7a59 is the final pre-QA Approved gate. The flaky-residual-silent-no-op signature that drove commits 3078f35 through 2d72012 was originally captured on CD-Windows; the rollback's "no hidden window, no message pump dependency" argument needs to land for that environment specifically, not just CI's windows-latest. Reasonable to do one round-trip of SVG / HTML / RTF + multi-format markdown on CD-Windows after several different prior clipboard states (text-only, multi-format, raster image) before merging.
  • Phase 2 tracker #147 stays open for _windows_read_image / _windows_write_image port + DIB ↔ PNG conversion.
  • Test count drift in the PR body and the test-plan checkbox are both stale; not blocking.

Verdict

QA Failed — F1 CHANGELOG consolidation. The implementation rewrite is sound and noticeably simpler than the R5 state; the docstring narrative in clipboard_win32.py is excellent. The CHANGELOG just needs to describe what shipped, not the dev journey through OLE and back.

@cmeans

cmeans commented May 11, 2026

Copy link
Copy Markdown
Owner

Audit: applying QA Failed per Round 6 review above. Five new commits walk through one more diagnostic loop before abandoning the OLE pivot and rolling back to raw SetClipboardData with GMEM_MOVEABLE HGLOBAL handles — matches Chromium / pyperclip / pyclip pattern, ~400 lines of OLE machinery removed. R5's F1 doc drift resolved as a side-effect of the rollback (CLAUDE.md/README's SetClipboardData language is accurate again). Module docstring at clipboard_win32.py:27-71 has an excellent root-cause narrative. Single new finding: F1 substantive — Unreleased CHANGELOG has 3 bullets describing the dev journey (pywin32-rewrite → OLE-pivot → OLE-rollback) rather than what shipped; per #138 R6 precedent, consolidate to the single accurate Changed bullet.

cmeans-claude-dev Bot and others added 2 commits May 11, 2026 19:00
The Windows clipboard chain race documented in the prior dev11 commit
4ca7a59 is NOT eliminated by switching to raw SetClipboardData. The
control experiment in dev/windows-clipboard-observers.ps1 proved the
race is observer-caused: with cbdhsvc Clipboard History service stopped
and the AllowClipboardHistory / AllowCrossDeviceClipboard /
SmartClipboard\Disabled policies set, the silent-no-op symptom vanishes
(mc-017 race-bucket 3/3 PASS, zero in-suite 1st-attempt recoveries).
Re-enabling those observers brings the symptom back with the same
fingerprint. Root cause confirmed via Microsoft Q&A 1327362: Windows 11
posts WM_CLIPBOARDUPDATE asynchronously, letting chain observers
re-open the clipboard immediately after our CloseClipboard and clobber
our write.

The race is inherent to the OS, not specific to our code. .NET carries
a 10x100ms retry on OleSetClipboard, Chromium has 5x5ms on OpenClipboard,
arboard's docs say "add a sleep()". This commit adds the equivalent
engineering tax on the verify side, matching .NET's defaults:

  - After CloseClipboard, open the clipboard read-side and call
    IsClipboardFormatAvailable for every format we wrote.
  - If any format is missing, retry the whole write transaction.
  - Up to 10 attempts at 100ms each (matches Clipboard.SetDataObject).

Happy path adds one Open/Close pair (microseconds). Worst-case latency
on a fully-contended write is ~1 second. Users who want zero tax can
run dev/windows-clipboard-observers.ps1 -Mode Disable to quiesce the
documented observers; the script saves current state and a -Mode Restore
puts everything back.

670 -> 673 unit tests pass; 4 new tests cover the verify-retry path
(verifies each format after close, retries on missing format, raises
after budget exhausted, no retry when first verify passes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empirical evidence from QEMU on dev12 showed the .NET-shaped verify-
retry made the race STRICTLY WORSE under active chain observers
(race-bucket 4/6 PASS on dev11 dropped to 0/6 PASS on dev12). Each
verify pair generates an additional WM_CLIPBOARDUPDATE notification
that amplifies observer contention rather than masking it. Revert
to dev11's shape -- one Open/Empty/Set/Close transaction, no
post-write verify, no retry on success -- which matches every major
Windows clipboard library:

  Chromium    raw SetClipboardData, 5x5ms OpenClipboard retry only
  Qt          OleSetClipboard, 3x100ms on CLIPBRD_E_CANT_OPEN only
  clipboard-win (Rust FFI)  no retry, no verify
  pyperclip / pyclip        no retry, no verify
  .NET WinForms             10x100ms on OpenClipboard, no post-write verify

The race remains. Users who need zero-flake can run
dev/windows-clipboard-observers.ps1 -Mode Disable to quiesce
Clipboard History, Cloud Clipboard, Suggested Actions, and cbdhsvc.
The QEMU control experiment showed that fully eliminates the
symptom (mc-017 race-bucket 3/3 PASS, zero 1st-attempt recoveries).

Diagnostic added: GetClipboardSequenceNumber sampled before and
after every write transaction. Per MSDN, immediate-rendering writes
MUST advance the sequence number synchronously to CloseClipboard.
A zero delta emits a WARNING via Python logging (routes to the MCP
host's log file, never to chat) because it indicates a different
bug class -- the kernel did not register our write -- distinct from
the observer race. With logging.DEBUG / MCP_CLIPBOARD_DEBUG=1 /
--debug, the wrapper also emits the seq_before/seq_after/delta on
every write so user-reported flakes can be diagnosed without live
re-instrumentation.

Sources cited in code comments and CHANGELOG:
- Microsoft Q&A 1327362 (WM_CLIPBOARDUPDATE asynchronously posted)
- Raymond Chen 2025-05-08 (Clipboard History is observer-only)
- Chromium clipboard_win.cc (no post-write verify)
- clipboard-win raw.rs (no retry)
- dotnet/wpf#9901 (Microsoft says "use WinForms SetDataObject" which retries on OPEN only)

670 -> 673 unit tests pass; new tests cover the seq-number canary
WARNING firing on zero delta, staying quiet on non-zero delta, and
the DEBUG seq-delta log line emission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@cmeans cmeans left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

QA Round 7 — QA Failed

Reviewed at head ab05aef. Two new commits walk through one more verify-retry attempt (75bcb0f) that turned out to amplify the chain-observer race and was reverted (ab05aef), then add a sequence-number canary diagnostic and a dev/windows-clipboard-observers.ps1 user-workaround script. The R6 F1 CHANGELOG consolidation finding was not addressed — three Unreleased bullets still describe the dev journey through OLE.

Round 6 finding — status

R6 finding Resolution
F1 substantive — CHANGELOG consolidation (drop the OLE-pivot ### Changed bullet and OLE-rollback ### Fixed bullet, leaving only the pywin32-rewrite bullet to reflect shipping state) Not addressed. The OLE narrative is still in CHANGELOG.md:53-84 (Changed bullet 2: "Windows writes go through OleSetClipboard + OleFlushClipboard...") and CHANGELOG.md:87-114 (Fixed bullet 1: "replaced the entire OleSetClipboard + OleFlushClipboard write path..."). Carries forward to R7 unchanged.

What happened between R6 and R7 — verify-retry try-and-revert + canary

Commit What it does Outcome
75bcb0f After CloseClipboard, re-open clipboard and call IsClipboardFormatAvailable for every format we wrote. Retry whole transaction if any format is missing. 10×100ms budget, matching .NET's Clipboard.SetDataObject defaults. Tested on QEMU dev12 and made the race strictly worse: race-bucket dropped from 4/6 PASS to 0/6 PASS. Each verify pair generates an additional WM_CLIPBOARDUPDATE that amplifies observer contention.
ab05aef Revert 75bcb0f's verify-retry. Add a GetClipboardSequenceNumber canary that samples seq before/after the write transaction and emits logger.warning if delta is 0 (kernel didn't register the write — distinct from the documented chain-observer race where seq advances but observers clobber afterward). Also add dev/windows-clipboard-observers.ps1 to disable Clipboard History / Cloud Clipboard / Suggested Actions / cbdhsvc for users who need zero-flake behavior. Matches what every mature Windows clipboard library does (Chromium / Qt / clipboard-win / pyperclip / .NET WinForms — none post-write-verify). Sources cited in code comments and CHANGELOG.

The verify-retry try-and-revert is fine as a dev journey — already captured in git history; doesn't need a CHANGELOG entry. The two NEW R7 CHANGELOG bullets (observers script + seq canary) are accurate and useful.

Round 7 verification

  • uv run pytest -q673 passed, 1 skipped, 19 deselected, 5 xfailed in 3.99s (+3 over R6's 670 from the seq-canary tests).
  • uv run ruff check src/ tests/ clean. uv run ruff format --check src/ tests/ clean. uv run mypy src/ clean.
  • gh pr view 146 --json statusCheckRollup — 21 actual CheckRuns SUCCESS, 8 SKIPPED. integration-windows SUCCESS on ab05aef.
  • dev/windows-clipboard-observers.ps1 (263 LoC) — script header is well-documented, captures Disable/Restore symmetry via JSON state in $env:LOCALAPPDATA, calls out Administrator requirement and reboot recommendation, enumerates the four toggles. Reasonable scope and reversibility.
  • gh pr view 146 --json closingIssuesReferences returns [#143].
  • Test plan checkboxes 1–4 ticked (verified locally / via CI status); checkbox 1 text updated from "654" to "673" to match actual. Checkbox 5 (post-merge QEMU sanity) remains the maintainer task.

Findings

F1 — substantive — CHANGELOG consolidation (carried over from R6 F1). ## [Unreleased] still contains three bullets describing the OLE excursion that, from a release-notes perspective, never happened on this PR:

  • CHANGELOG.md:53-84### Changed bullet 2: "Windows writes go through OleSetClipboard + OleFlushClipboard, dispatched onto a dedicated COM worker thread." Not true of shipping state — R6 rolled this back.
  • CHANGELOG.md:87-114### Fixed bullet 1: "replaced the entire OleSetClipboard + OleFlushClipboard write path with the canonical raw-SetClipboardData pattern..." Describes a fix to a problem that never shipped — the OLE path lived entirely within this un-merged PR's commit history (d3d7372 through 8535045).

Same fix as R6:

  1. Drop the OLE-pivot ### Changed bullet 2.
  2. Drop the OLE-rollback ### Fixed bullet 1.
  3. Optionally tighten the remaining ### Changed bullet 1 to call out the explicit GMEM_MOVEABLE HGLOBAL handle convention.

The R7-added ### Fixed bullets (observers script + seq canary) are accurate and stay. The technical narrative about why OLE didn't fit (which is excellent) lives in the clipboard_win32.py:43-71 module docstring — keep it there.

Project precedent for this is #138's R6 revert: the test plan and PR body gained a "What this PR is NOT" bullet documenting the lesson, but the CHANGELOG was the SVG-only entry that reflected what actually shipped — no mention of the reverted CRLF cleanup.

Heads-up (not findings)

  • PR body checkbox 1 count drift (R6 → R7 was 620654, R7 actual is 673) — I ticked the boxes I verified in this session and updated the count to 673 so the body matches reality. If Dev consolidates the CHANGELOG and pushes a fresh commit, the test count will drift again; consider just removing the specific number from the test-plan checkbox text and saying "full local suite passes" since it churns every round.
  • Maintainer-side CD-Windows live verification on ab05aef remains the final pre-Approval gate per checkbox 5. The chain-observer race that drove the verify-retry try-and-revert was only observed on CD-Windows, not in CI's windows-latest (which has no clipboard chain monitors).
  • Phase 2 tracker #147 stays open.

Verdict

QA Failed — R6 F1 unaddressed; same CHANGELOG consolidation needed. Implementation is sound and the R7 commits are net-positive (observers script and seq canary are both useful additions). The OLE narrative just needs to come out of the changelog before this ships.

@cmeans

cmeans commented May 12, 2026

Copy link
Copy Markdown
Owner

Audit: applying QA Failed per Round 7 review above. Two new commits add a useful seq-number canary diagnostic + a dev/windows-clipboard-observers.ps1 user-workaround script; a verify-retry attempt in between (75bcb0f) was reverted after QEMU showed it amplified the chain-observer race. R6 F1 CHANGELOG consolidation NOT addressed — the OLE-pivot ### Changed bullet 2 and OLE-rollback ### Fixed bullet 1 are still in Unreleased and describe a journey that never shipped. Same fix as R6. Test plan checkboxes 1-4 ticked, count updated to 673.

…e race

QEMU tiebreaker run (dev13 with chain observers DISABLED) showed the
seq-number canary added in commit ab05aef itself contributes
contention: race-bucket 0/6 PASS and six 1st-attempt recoveries on
the same observer posture that on canary-free dev11 produced 6/6
PASS and zero recoveries. Even two GetClipboardSequenceNumber calls
bracketing the Open/Empty/Set/Close transaction -- structurally
passive, no clipboard handle taken, no extra WM_CLIPBOARDUPDATE
generated -- measurably changes the race timing on real Windows 11
desktops.

Lesson preserved in code + CHANGELOG: do not add work to the write
hot path even when it looks free.

Wrapper is now the bare canonical Win32 pattern matching every
major Windows clipboard library (Chromium, Qt, clipboard-win,
pyperclip, .NET WinForms). The race-bucket result under active
chain observers is what the entire Windows clipboard ecosystem
lives with; users who need zero-flake can run the control script
at dev/windows-clipboard-observers.ps1.

670 -> 671 unit tests pass; three seq-canary-specific tests removed
and one architectural-smell test (GetClipboardSequenceNumber must
NOT be called from the write path) added in their place.

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

Ready for QA Dev work complete — QA can begin review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

clipboard_copy(mime_type=image/svg+xml) silently no-ops on CD/Windows in v2.6.1

1 participant