From 916cf98d5d8a448ec6515d211c6fe980a46a1694 Mon Sep 17 00:00:00 2001 From: josecarlosgonzalezroque-design Date: Mon, 26 Oct 2026 07:52:18 -0600 Subject: [PATCH] Use native Windows ownership in the shared Git blob reader Integrate the gated Job Object owner and launch diagnostics from PR5463 and PR5524. Exercise the shared factory, preserve protocol fixtures and include the companion module in standalone consumer fixtures. Prepared with substantial OpenAI Codex assistance. Predecessor attribution and native validation scope are recorded in the integration note. --- .../windows-git-reader-integration.md | 166 ++++++++++ scripts/research_package.py | 28 +- scripts/research_windows_git.py | 293 ++++++++++++++++++ tests/benchmark_research_scaffold.py | 10 +- tests/test_research_commit_bytes.py | 2 +- .../test_research_descendant_source_roles.py | 7 +- tests/test_research_export_drive_paths.py | 2 +- tests/test_research_export_topology.py | 2 +- tests/test_research_git_compatibility.py | 6 +- tests/test_research_git_deadline.py | 1 + tests/test_research_git_installed.py | 10 +- tests/test_research_git_pipe_errors.py | 3 +- tests/test_research_git_windows.py | 225 ++++++++++++++ tests/test_research_head_pin_paths.py | 2 +- tests/test_research_input_paths.py | 3 + tests/test_research_onboarding.py | 3 + tests/test_research_package_diagnostics.py | 2 +- tests/test_research_replay_selection.py | 4 +- tests/test_research_scaffold_batch.py | 16 +- tests/test_research_source_history.py | 2 +- tests/test_research_sparse_planning.py | 9 +- tests/test_research_symlink_inputs.py | 1 + 22 files changed, 769 insertions(+), 28 deletions(-) create mode 100644 docs/research/windows-git-reader-integration.md create mode 100644 scripts/research_windows_git.py create mode 100644 tests/test_research_git_windows.py diff --git a/docs/research/windows-git-reader-integration.md b/docs/research/windows-git-reader-integration.md new file mode 100644 index 0000000000..a087002aa9 --- /dev/null +++ b/docs/research/windows-git-reader-integration.md @@ -0,0 +1,166 @@ +# Windows ownership in the shared Git blob reader + +The shared `git_blobs` reader now uses the gated Job Object owner and launch +status channel from [PR5463](https://github.com/CrocSwap/integer-mult-bounds/pull/5463) +and [PR5524](https://github.com/CrocSwap/integer-mult-bounds/pull/5524) on Windows. +Previously, a descendant retaining a pipe could survive the Git leader or keep +a response blocked after the direct-child timeout. The reader now stops the +owned job before closing buffered pipes on success, timeout, generator close +and consumer failure. The requested executable's native launch errors remain +exceptions; only a successfully launched command exiting 129 can trigger the +existing compatibility retry. + +This is an author-checked tooling integration. It makes no mathematical, +selected-frontier, general subprocess, or independent-review claim. + +## Task and source boundaries + +- Task: `portable-replay`, verifier engineering, chosen from the production + adoption handoff in [Discussion1166](https://github.com/CrocSwap/integer-mult-bounds/discussions/1166) + and PR5524's **Open next questions**. The retrieved catalogue card had nonce + `0f543fba2362863680106573ad15451a`, scope key `91a9a2afec282c926282`, + and priority revision 7. +- **Changed scope:** integrate the existing native owner at one shared process + factory and exercise its consumers, rather than compare compressed replays. + This addresses the predecessor's explicit adoption gap without opening a + new construction family. +- Started from canonical main `5e0052ca0aa3036a3afa8b0438927377c8d97d53`; + refreshed to `df0137efd35e11b4445734bf612bdd4c7ebc330c` before submission. + Priority revision 7 and the relevant shared reader/predecessors were unchanged. + The initial selected orientation returned + `PASS_RESEARCH_BASELINE_BINDINGS_ONLY` for frontier5413. The refresh selected + frontier5599; no cold mathematical replay of either selection is claimed. +- Direct sources: PR5463 head `991da9010925914ec64a5293286edb10a7910218` + and PR5524 head `b11f5ab7df36fbde44e9f2de3498e3e77f2eb4ee`. + `scripts/research_windows_git.py` combines PR5524's + `vendor/owned_pipe.py` and `spawn_checked.py`; its wrapper uses the same-module + owner instead of accepting an owner module parameter. Frozen packages remain + unchanged. [PR5461](https://github.com/CrocSwap/integer-mult-bounds/pull/5461) + is the predecessor diagnosis, not a new audit result here. +- Milestone: shared-tool integration with bounded native regression evidence. + Success means descendant termination observed **before fixture teardown**, + exact binary/source-pin behavior, preserved launch exceptions and fallback, + and existing consumer checks. A direct-child replacement must fail all six + lifetime cases. No cost-law, supplier-restoration or kappa claim applies. +- Overlap: read the start/handoff discussions and active discussion listing; + targeted live PR searches for `5524` and `Windows Git` found the predecessor + work, but no equivalent shared integration. The offline helper covered only + 3 of 7,611 local tracked records in the initial sparse checkout and had no + refreshed remote cache. The canonical investigation thread could not be + fetched. These checks are partial coverage, not proof of novelty. + +## Integration contract + +`_git_blob_popen` is the launch boundary. POSIX retains its existing `Popen` +session and process-group behavior. Windows lazily imports the companion module, +including when the reader is loaded by file path under isolated Python. Protocol +fixtures patch this boundary; installed-Git and native ownership fixtures retain +the real factory. The scaffold check still observes two metadata calls and one +blob stream; its optional benchmark also counts the Git launch inside the native +gate. No timing improvement is claimed. Standalone copies of `research_package.py` that read blobs on +Windows must include `research_windows_git.py` beside it. Temporary CLI fixtures +have been updated accordingly. + +Two existing fixture assumptions needed Windows fixes: the historical object +store's `alternates` file now uses literal LF instead of translated CRLF, and +the sparse nonregular-manifest test creates its Git symlink-mode entry directly +without requiring filesystem symlink privilege. The tested predicates remain +the same. + +The native helper creates the job and event, starts an isolated Python gate, +assigns that gate to the job, and only then releases it to launch the requested +argv. A separate bounded JSON status file preserves launch diagnostics without +touching Git's binary stdout. Job termination is idempotent and synchronized. +The per-response timer still excludes time spent in the consumer. Startup has a +separate ten-second report deadline, and cleanup retains the predecessor's +ten-second accounting bound. + +The planner treats the companion module as a shared control, so a helper-only +change selects all registered packages, including omitted sparse manifests. +`git_source_rows`, separate `git()` metadata calls and the registered verifier +process runner are outside this change. + +## Reproduction and evidence + +Host: Windows 11 build 26300, CPython 3.12.10, Git for Windows 2.53.0.windows.3. +Use Python 3.12 and installed Git from the repository root. Windows process +creation and a writable temporary directory are required. Warnings were errors. + +```sh +python -W error -B -m unittest discover -s tests -p 'test_research_git_*.py' -v +python -W error -O -B tests/test_research_git_windows.py -v +python -W error -B -m unittest discover -s tests -p 'test_research_export_*.py' -v +python -W error -B tests/test_research_commit_bytes.py -v +python -W error -B tests/test_research_source_history.py -v +python -W error -B tests/test_research_scaffold_batch.py -v +python -W error -B tests/test_research_sparse_planning.py -v +python -W error -B tests/test_research_head_pin_paths.py -v +python -W error -B tests/test_research_onboarding.py -v +python -W error -B tests/test_research_package_diagnostics.py -v +python -W error -B tests/test_research_replay_selection.py -v +python -W error -B tests/test_research_input_paths.py -v +python -W error -B tests/test_research_symlink_inputs.py -v +python -W error -B tests/test_research_descendant_source_roles.py -v +git diff --check +``` + +All listed commands passed. The final results were: + +| Check | Tests run | Skipped on this host | +|---|---:|---:| +| Git reader suites, including five native Windows tests | 67 | 8 | +| Native Windows tests under `-O` | 5 | 0 | +| Export suites | 26 | 0 | +| Commit bytes / source history | 11 / 13 | 0 | +| Scaffold / sparse planning / head-pin paths | 3 / 11 / 3 | 0 / 0 / 2 | +| Onboarding / diagnostics | 27 / 16 | 0 | +| Replay selection / input paths / symlink inputs | 9 / 5 / 14 | 1 / 0 / 5 | +| Descendant source-role compatibility | 10 | 0 | + +The source-role fixture requires its historical objects in the local Git store; +these were materialized before the successful run in this partial clone. The +optional timing benchmark was not run. The scaffold regression verifies the +three-call budget and exact generated pins instead. + +The native regression retains process handles rather than relying on PID lookup. +Its six lifecycle cases are success, header stall, payload stall, terminator +stall, early close and consumer error. A four-second rescue guard bounds broken +implementations; any use of that guard fails the test. The direct-child mutation +is required to produce six failures and no fixture errors. Launch comparisons +check exception type and all five native error fields. The launched-exit control +checks 0, 1, 7 and 129; only 129 retries, returning two exact 80,000-byte blobs. +Unicode argv/environment/cwd and unmodified binary stdout have a separate case. + +Before integration, the unchanged PR5524 native verifier passed its 25 named +case groups on this host. That is predecessor evidence, not a substitute for +the shared-reader checks above. + +## Open next questions and limits + +1. Run the shared consumer and POSIX descendant suites on Linux/macOS. This + session has a native Windows host only; POSIX cases and unsupported filename + cases skip. Windows filesystem-symlink tests also skip without the privilege; + junction tests and the Git-index symlink-mode check do run. +2. Review the adopted adapter and repeat native checks on another Windows/Python + version and under restrictive enclosing jobs. Hard owner death during setup, + abrupt parent termination and job-policy denial are not newly certified here. +3. The helper supports the reader's nonempty string argv, binary streams and + context-manager usage. It does not implement general `Popen` options. Launch + timing does not bound all Windows process-creation or filesystem operations. + The status file is a trusted local control channel, not a security boundary. + Installed older Git was not available; the launched-129 fixture exercises the + retry with actual current Git serving the resulting blobs. + +## Attribution + +Credit Mihir Srivastava for the PR5463 owner and PR5524 launch diagnostics; +ywz65 for the gated Job Object lifecycle (PR2469) and retained native-handle +observer (PR2452); Brian / JoyboyBrian for response deadlines (PR4738) and POSIX +lifetime controls (PR5185); Nick Petalas for the blob/compatibility reader +(PR2752); and Douglas Colkitt for the package runner. Arham Tahir, Woklerx, +yssite, Paul Romeo, Tekalign and the retained source/fixture lineage keep their +credit. The predecessor `NOTICE` and `vendor/NOTICE` remain intact. + +Integration and regressions prepared for josecarlosgonzalezroque-design with +substantial OpenAI Codex assistance, one agent. Apache-2.0. No independent human +or separate-agent review has been performed by this session. diff --git a/scripts/research_package.py b/scripts/research_package.py index 48fe9feb46..4e75869665 100644 --- a/scripts/research_package.py +++ b/scripts/research_package.py @@ -173,7 +173,7 @@ def _closed_stdin_error(error): def _stop_git_blob_process(process): - """Stop this reader's owned POSIX group even after its leader exits.""" + """Stop this reader's owned process tree even after its leader exits.""" try: if os.name == "posix": # Reap an exited leader first: signaling a group containing only @@ -193,7 +193,7 @@ def _git_blob_response_deadline(stop): On POSIX the reader owns a session: ordinary descendants retaining a pipe are stopped with the leader. Escaped sessions and separate git() calls are - outside this contract; non-POSIX retains the direct-child deadline. + outside this contract. Windows uses the gated kill-on-close Job Object. """ expired = threading.Event() @@ -212,13 +212,26 @@ def terminate(): raise subprocess.TimeoutExpired("git cat-file blob response", GIT_BLOB_RESPONSE_TIMEOUT) +def _git_blob_popen(argv, **kwargs): + """Single launch seam for native ownership and protocol-only fixtures.""" + if os.name == "nt": + # Also support callers importing this file directly under Python -I. + sys.path.insert(0, str(Path(__file__).resolve().parent)) + try: + from research_windows_git import LaunchCheckedPopen + finally: + sys.path.pop(0) + return LaunchCheckedPopen(argv, **kwargs) + return subprocess.Popen(argv, **kwargs) + + def _git_blob_batch(root, specs, nul): argv = ["git", "cat-file", "--batch"] + (["-z"] if nul else []) # Usage diagnostics from the unsupported-option probe are expected. Avoid # a second pipe that could block while we consume the framed stdout reply. - with subprocess.Popen(argv, cwd=root, env=git_source_env(), stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, - start_new_session=(os.name == "posix")) as process: + with _git_blob_popen(argv, cwd=root, env=git_source_env(), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + start_new_session=(os.name == "posix")) as process: stopped = False def stop(): @@ -261,7 +274,7 @@ def stop(): finally: # Terminate ordinary descendants before closing buffered pipes. This # also covers successful leader exit and an early generator close. - if os.name == "posix": + if os.name in ("posix", "nt"): stop() # Option rejection can close stdin before our first write/flush. try: @@ -897,7 +910,8 @@ def plan_details(root, changed): head_tracked = tracked_input_paths(root) tracked = head_tracked | changed replay_changed = sorted(changed.intersection({ - "scripts/research_package.py", ".github/workflows/research-packages.yml", + "scripts/research_package.py", "scripts/research_windows_git.py", + ".github/workflows/research-packages.yml", ".github/workflows/research-package-batch.yml"})) # Group literal Git paths once. Keep removed paths and exact spellings. changed_packages = {} diff --git a/scripts/research_windows_git.py b/scripts/research_windows_git.py new file mode 100644 index 0000000000..54e33b7556 --- /dev/null +++ b/scripts/research_windows_git.py @@ -0,0 +1,293 @@ +"""Windows Job Object ownership and launch diagnostics for Git blob streams. + +Adapted from Mihir Srivastava's PR5463/PR5524 adapters and ywz65's gated +Job Object implementation, with their Apache-2.0 attribution retained. +Shared-reader integration for josecarlosgonzalezroque-design with substantial +OpenAI Codex assistance. This is a binary, context-managed argv subset of Popen. +""" +from contextlib import contextmanager +import ctypes +from ctypes import wintypes +import json +from pathlib import Path +import tempfile +import subprocess +import sys +import threading +import time +import uuid + +REAL_POPEN = subprocess.Popen + + +@contextmanager +def windows_pipe(argv, gate, **kwargs): + """Yield a gated process and an idempotent, thread-safe whole-job stop. + + Only the job handle owner may release the gate. The requested command's + stdin/stdout are inherited through the gate without Python-level copying. + """ + class BasicLimits(ctypes.Structure): + _fields_ = [("ProcessTime", ctypes.c_int64), ("JobTime", ctypes.c_int64), + ("Flags", wintypes.DWORD), ("MinWorkingSet", ctypes.c_size_t), + ("MaxWorkingSet", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), ("Priority", wintypes.DWORD), + ("Scheduling", wintypes.DWORD)] + + class IOCounts(ctypes.Structure): + _fields_ = [(name, ctypes.c_uint64) for name in + ("ReadOps", "WriteOps", "OtherOps", "ReadBytes", "WriteBytes", "OtherBytes")] + + class ExtendedLimits(ctypes.Structure): + _fields_ = [("Basic", BasicLimits), ("IO", IOCounts), + ("ProcessMemory", ctypes.c_size_t), ("JobMemory", ctypes.c_size_t), + ("PeakProcessMemory", ctypes.c_size_t), ("PeakJobMemory", ctypes.c_size_t)] + + class Accounting(ctypes.Structure): + _fields_ = [(name, ctypes.c_int64) for name in + ("UserTime", "KernelTime", "PeriodUserTime", "PeriodKernelTime")] + [ + (name, wintypes.DWORD) for name in + ("PageFaults", "TotalProcesses", "ActiveProcesses", "TerminatedProcesses")] + + api = ctypes.WinDLL("kernel32", use_last_error=True) + + def native(name, result, *arguments): + fn = getattr(api, name) + fn.restype, fn.argtypes = result, arguments + return fn + + handle, dword, boolean = wintypes.HANDLE, wintypes.DWORD, wintypes.BOOL + create_job = native("CreateJobObjectW", handle, ctypes.c_void_p, wintypes.LPCWSTR) + set_info = native("SetInformationJobObject", boolean, handle, ctypes.c_int, ctypes.c_void_p, dword) + assign = native("AssignProcessToJobObject", boolean, handle, handle) + terminate = native("TerminateJobObject", boolean, handle, wintypes.UINT) + query = native("QueryInformationJobObject", boolean, handle, ctypes.c_int, + ctypes.c_void_p, dword, ctypes.c_void_p) + create_event = native("CreateEventW", handle, ctypes.c_void_p, boolean, boolean, wintypes.LPCWSTR) + set_event = native("SetEvent", boolean, handle) + close = native("CloseHandle", boolean, handle) + + def checked(ok): + if not ok: + raise ctypes.WinError(ctypes.get_last_error()) + return ok + + job = checked(create_job(None, None)) + event, process, assigned = None, None, False + stopped = False + lock = threading.Lock() + + def stop(): + nonlocal stopped + with lock: + if stopped: + return + checked(terminate(job, 1)) + deadline = time.monotonic() + 10 + while True: + state = Accounting() + checked(query(job, 1, ctypes.byref(state), ctypes.sizeof(state), None)) + if not state.ActiveProcesses: + break + if time.monotonic() >= deadline: + raise RuntimeError("owned Git process cleanup timed out") + time.sleep(0.01) + stopped = True + + try: + limits = ExtendedLimits() + limits.Basic.Flags = 0x2000 # JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + checked(set_info(job, 9, ctypes.byref(limits), ctypes.sizeof(limits))) + name = "Local\\research-git-stream-" + uuid.uuid4().hex + event = checked(create_event(None, True, False, name)) + if ctypes.get_last_error() == 183: + raise ValueError("Git ownership gate name already exists") + process = REAL_POPEN([sys.executable, "-I", "-S", "-c", gate, name, + json.dumps(argv, ensure_ascii=False)], **kwargs) + checked(assign(job, int(process._handle))) + assigned = True + checked(set_event(event)) + yield process, stop + finally: + try: + if process is not None: + if assigned: + stop() + elif process.poll() is None: + process.kill() + process.wait(timeout=10) + finally: + try: + if process is not None: + # In the actual reader these are already closed. Setup + # failure can reach here before the reader owns any streams. + for stream in (process.stdin, process.stdout, process.stderr): + if stream is not None: + stream.close() + finally: + try: + if event: + checked(close(event)) + finally: + checked(close(job)) + + +class OwnedPopen: + """The Popen subset used by the blob reader, with job-aware kill/exit. + + This is a reader adapter, not a general subprocess replacement. + The caller must use the context manager, as the actual reader does. + """ + def __init__(self, argv, gate, **kwargs): + self.context = windows_pipe(argv, gate, **kwargs) + self.process, self.stop = self.context.__enter__() + + def __getattr__(self, name): + return getattr(self.process, name) + + def kill(self): + self.stop() + + def __enter__(self): + return self + + def __exit__(self, *exception): + return self.context.__exit__(*exception) + + +STARTUP_TIMEOUT = 10 +MAX_REPORT_BYTES = 65536 + +# REPORT is inserted as a Python string literal by make_gate. The requested +# command receives its original argv/env/cwd/standard handles, not this channel. +GATE = r''' +import ctypes +from ctypes import wintypes +import json +import os +import subprocess +import sys +api = ctypes.WinDLL('kernel32', use_last_error=True) +api.OpenEventW.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.LPCWSTR] +api.OpenEventW.restype = wintypes.HANDLE +api.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] +api.WaitForSingleObject.restype = wintypes.DWORD +api.CloseHandle.argtypes = [wintypes.HANDLE] +api.CloseHandle.restype = wintypes.BOOL +event = api.OpenEventW(0x00100000, False, sys.argv[1]) +if not event: + raise ctypes.WinError(ctypes.get_last_error()) +try: + if api.WaitForSingleObject(event, 30000) != 0: + raise RuntimeError('Git launch ownership gate was not released') +finally: + api.CloseHandle(event) + +def report(value): + with open(REPORT + '.pending', 'x', encoding='utf-8') as stream: + json.dump(value, stream, ensure_ascii=True) + os.replace(REPORT + '.pending', REPORT) + +# Only this OSError is reported as a requested-executable launch error. Failure +# of the gate/reporting mechanism fails closed via the parent's bounded wait. +try: + child = subprocess.Popen(json.loads(sys.argv[2])) +except OSError as error: + report({'status': 'oserror', 'errno': error.errno, + 'winerror': error.winerror, 'strerror': error.strerror, + 'filename': error.filename, 'filename2': error.filename2}) + raise SystemExit(1) +report({'status': 'started'}) +raise SystemExit(child.wait()) +''' + + +def make_gate(report): + return 'REPORT = ' + repr(str(report)) + '\n' + GATE + + +def read_payload(path): + with path.open('rb') as stream: + return stream.read(MAX_REPORT_BYTES + 1) + + +def interpret_report(payload): + # Truncated, oversized, malformed or unknown reports never become success, + # and never trigger the reader's legacy retry. + if len(payload) > MAX_REPORT_BYTES: + raise ValueError('oversized Git launch report') + value = json.loads(payload) + if value == {'status': 'started'}: + return + fields = {'status', 'errno', 'winerror', 'strerror', 'filename', 'filename2'} + if (not isinstance(value, dict) or set(value) != fields + or value['status'] != 'oserror' + or type(value['errno']) is not int or type(value['winerror']) is not int + or not isinstance(value['strerror'], str) + or any(value[k] is not None and not isinstance(value[k], str) + for k in ('filename', 'filename2'))): + raise ValueError('invalid Git launch report') + # Windows OSError maps the original native code to the appropriate subclass + # and errno, just as direct CreateProcess/Popen did in the gate. + raise OSError(value['errno'], value['strerror'], value['filename'], + value['winerror'], value['filename2']) + + +def read_report(path): + interpret_report(read_payload(path)) + + +def await_launch(process, report, argv, timeout): + deadline = time.monotonic() + timeout + while True: + if report.exists(): + try: + payload = read_payload(report) + except PermissionError: + # Windows can briefly deny opening a newly renamed report. + # Retry only channel I/O, within the original startup bound. + # A requested executable's PermissionError is reconstructed + # below, outside this handler, and must propagate immediately. + pass + else: + interpret_report(payload) + return + elif process.poll() is not None: + # A fast process may publish between the existence check and poll. + if not report.exists(): + raise RuntimeError('Git ownership gate exited without a launch report') + if time.monotonic() >= deadline: + raise subprocess.TimeoutExpired(argv, timeout) + time.sleep(0.01) + + +@contextmanager +def checked_pipe(argv, **kwargs): + """Report launch before returning streams; stop the owned job on any failure. + + The owner retains the PR5463 gated Job Object protocol. Startup has its own + bound; the existing per-response watchdog still excludes consumer pauses. + """ + if not isinstance(argv, list) or not argv or any(type(a) is not str for a in argv): + raise TypeError('Git launch wrapper requires a nonempty list of strings') + with tempfile.TemporaryDirectory(prefix='git launch status ') as temporary: + report = Path(temporary) / 'status.json' + with OwnedPopen(argv, make_gate(report), **kwargs) as process: + await_launch(process, report, argv, STARTUP_TIMEOUT) + yield process + + +class LaunchCheckedPopen: + """Only the Popen subset consumed by the blob reader, used with with.""" + def __init__(self, argv, **kwargs): + self.context = checked_pipe(argv, **kwargs) + self.process = self.context.__enter__() + + def __getattr__(self, name): + return getattr(self.process, name) + + def __enter__(self): + return self + + def __exit__(self, *exception): + return self.context.__exit__(*exception) diff --git a/tests/benchmark_research_scaffold.py b/tests/benchmark_research_scaffold.py index 6b899f548e..cffcc51bfa 100644 --- a/tests/benchmark_research_scaffold.py +++ b/tests/benchmark_research_scaffold.py @@ -51,11 +51,19 @@ def benchmark(pairs): for mode in (('before', 'after') if sample % 2 == 0 else ('after', 'before')): create = namespace['create'] if mode == 'before' else current.create commands, real = [], subprocess.Popen + real_blob = package._git_blob_popen def tracked(argv, *args, **kwargs): commands.append(argv) return real(argv, *args, **kwargs) + def tracked_blob(argv, *args, **kwargs): + # On Windows the isolated owner launches Git through its + # gate, outside the direct-Popen observer in this process. + if sys.platform == 'win32': + commands.append(argv) + return real_blob(argv, *args, **kwargs) start = time.perf_counter() - with patch.object(subprocess, 'Popen', side_effect=tracked): + with (patch.object(subprocess, 'Popen', side_effect=tracked), + patch.object(package, '_git_blob_popen', side_effect=tracked_blob)): target = create(roots[mode], 'benchmark-' + str(sample), 'bit-new-register-model', 'Benchmark fixture', sources) measurements.append({'sample': sample, 'mode': mode, diff --git a/tests/test_research_commit_bytes.py b/tests/test_research_commit_bytes.py index 3b367bfbc3..95910c2fa7 100644 --- a/tests/test_research_commit_bytes.py +++ b/tests/test_research_commit_bytes.py @@ -124,7 +124,7 @@ def test_unicode_and_wildcard_characters_are_literal_input_paths(self): self.assertEqual(self.check()['files'], 3) def test_cli_pass_fail_and_read_only_behavior(self): - for name in ('check_research_commit.py', 'research_package.py'): + for name in ('check_research_commit.py', 'research_package.py', 'research_windows_git.py'): self.write('scripts/'+name, (ROOT/'scripts'/name).read_bytes()) self.commit() script = self.root/'scripts/check_research_commit.py' diff --git a/tests/test_research_descendant_source_roles.py b/tests/test_research_descendant_source_roles.py index 5a357259cf..40d7cef5fe 100644 --- a/tests/test_research_descendant_source_roles.py +++ b/tests/test_research_descendant_source_roles.py @@ -32,7 +32,7 @@ def setUp(self): self.root = Path(temporary.name) self.original = json.loads(git(ROOT, 'show', ORIGINAL + ':' + PACKAGE + '/SOURCES.json')) self.manifest = json.loads((ROOT / PACKAGE / 'VERIFY.json').read_bytes()) - names = set(self.manifest['inputs']) | { + names = set(self.manifest['inputs']) | {'scripts/research_windows_git.py'} | { PACKAGE + '/' + name for name in ('SOURCES.json', 'VERIFY.json', 'TASK.json', 'README.md', 'verify.py', 'RESULT.json')} for name in names: @@ -49,7 +49,10 @@ def setUp(self): objects = Path(git(ROOT, 'rev-parse', '--git-path', 'objects').decode().strip()) if not objects.is_absolute(): objects = ROOT / objects - (self.root / '.git/objects/info/alternates').write_text(str(objects.resolve()) + '\n') + # Git treats a CR from Windows text newline conversion as part of the + # object-directory name. The alternates file requires literal LF. + (self.root / '.git/objects/info/alternates').write_bytes( + (objects.resolve().as_posix() + '\n').encode('utf-8')) git(self.root, 'config', 'user.name', 'Fixture') git(self.root, 'config', 'user.email', 'fixture@example.invalid') git(self.root, 'config', 'core.autocrlf', 'false') diff --git a/tests/test_research_export_drive_paths.py b/tests/test_research_export_drive_paths.py index f1a9f66334..6cba30ba9f 100644 --- a/tests/test_research_export_drive_paths.py +++ b/tests/test_research_export_drive_paths.py @@ -89,7 +89,7 @@ def git_fixture(self, names): repo = Path(tempfile.mkdtemp(prefix='repo-', dir=self.root)) scripts = repo / 'scripts' scripts.mkdir() - for name in ('export_research_package.py', 'research_package.py'): + for name in ('export_research_package.py', 'research_package.py', 'research_windows_git.py'): shutil.copyfile(ROOT / 'scripts' / name, scripts / name) def git(*args, data=None): return subprocess.check_output(['git', *args], cwd=repo, input=data, diff --git a/tests/test_research_export_topology.py b/tests/test_research_export_topology.py index 2c1e49fc1c..17608dd647 100644 --- a/tests/test_research_export_topology.py +++ b/tests/test_research_export_topology.py @@ -24,7 +24,7 @@ def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.root = Path(temporary.name) - for name in ('export_research_package.py', 'research_package.py'): + for name in ('export_research_package.py', 'research_package.py', 'research_windows_git.py'): path = self.root / 'scripts' / name path.parent.mkdir(exist_ok=True) shutil.copyfile(ROOT / 'scripts' / name, path) diff --git a/tests/test_research_git_compatibility.py b/tests/test_research_git_compatibility.py index 4e6a4c66a4..705633b5dc 100644 --- a/tests/test_research_git_compatibility.py +++ b/tests/test_research_git_compatibility.py @@ -56,7 +56,10 @@ def start(argv, **kwargs): children.append(child) return child - with patch.object(PACKAGE.subprocess, "Popen", side_effect=start): + # Protocol fixtures intentionally bypass native ownership; the native + # suite exercises the platform factory, including its exit-129 path. + with (patch.object(PACKAGE.subprocess, "Popen", side_effect=start), + patch.object(PACKAGE, "_git_blob_popen", side_effect=start)): yield calls, children self.assertTrue(all(child.poll() is not None for child in children)) @@ -128,6 +131,7 @@ def start(argv, **kwargs): return child with (patch.object(PACKAGE.subprocess, "Popen", side_effect=start), + patch.object(PACKAGE, "_git_blob_popen", side_effect=start), closing(PACKAGE.git_blobs(self.root, ["HEAD:empty", "HEAD:binary"])) as rows): self.assertEqual(next(rows), (blob_id(b""), b"")) with self.assertRaisesRegex(ValueError, "missing or non-blob"): diff --git a/tests/test_research_git_deadline.py b/tests/test_research_git_deadline.py index 31fba1d3a1..9432d10591 100644 --- a/tests/test_research_git_deadline.py +++ b/tests/test_research_git_deadline.py @@ -38,6 +38,7 @@ def start(argv, **kwargs): ready = Path(directory) / "response-ready" try: with (patch.object(PACKAGE.subprocess, "Popen", side_effect=start), + patch.object(PACKAGE, "_git_blob_popen", side_effect=start), patch.object(PACKAGE, "GIT_BLOB_RESPONSE_TIMEOUT", timeout, create=True)): yield Path(directory), children, calls finally: diff --git a/tests/test_research_git_installed.py b/tests/test_research_git_installed.py index 5da77cfbe3..d4954e5134 100644 --- a/tests/test_research_git_installed.py +++ b/tests/test_research_git_installed.py @@ -78,7 +78,8 @@ def installed_protocol(self): @contextmanager def observe(self, wait_for_probe_exit=False): """Record launches without replacing an executable, reply or pipe.""" - real_popen = subprocess.Popen + real_popen = PACKAGE._git_blob_popen + real_git = PACKAGE.git calls, children = [], [] def start(argv, **kwargs): @@ -91,8 +92,13 @@ def start(argv, **kwargs): self.assertEqual(child.wait(timeout=15), 129) return child + def git_call(root, *args): + calls.append(["git", *args]) + return real_git(root, *args) + try: - with patch.object(PACKAGE.subprocess, "Popen", side_effect=start): + with (patch.object(PACKAGE, "_git_blob_popen", side_effect=start), + patch.object(PACKAGE, "git", side_effect=git_call)): yield calls finally: try: diff --git a/tests/test_research_git_pipe_errors.py b/tests/test_research_git_pipe_errors.py index 1eab6148a9..9ec163bd8c 100644 --- a/tests/test_research_git_pipe_errors.py +++ b/tests/test_research_git_pipe_errors.py @@ -103,7 +103,8 @@ def kill_group(pid, signum): name=platform, environ=os.environ, killpg=kill_group)), patch.object(PACKAGE.signal, "SIGKILL", getattr(PACKAGE.signal, "SIGKILL", 9), create=True), - patch.object(PACKAGE.subprocess, "Popen", side_effect=start)): + patch.object(PACKAGE.subprocess, "Popen", side_effect=start), + patch.object(PACKAGE, "_git_blob_popen", side_effect=start)): yield calls, pipes finally: self.assertTrue(all(child.poll() is not None for child in children)) diff --git a/tests/test_research_git_windows.py b/tests/test_research_git_windows.py new file mode 100644 index 0000000000..1a76cd8e20 --- /dev/null +++ b/tests/test_research_git_windows.py @@ -0,0 +1,225 @@ +"""Native Windows ownership at the shared Git reader, before fixture teardown. + +Fixtures build on Mihir Srivastava's PR5524 and ywz65's native handle observer. +Integration regressions for josecarlosgonzalezroque-design with substantial +OpenAI Codex assistance. Apache-2.0. +""" +from contextlib import closing +import ctypes +from ctypes import wintypes +import importlib.util +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import threading +import time +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('windows_blob_package', ROOT / 'scripts/research_package.py') +PACKAGE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(PACKAGE) + +# Retain a native handle: PID reuse and later teardown cannot manufacture PASS. +PRODUCER = r''' +import os, subprocess, sys, time +from pathlib import Path +root, mode = Path(sys.argv[1]), sys.argv[2] +child = subprocess.Popen([sys.executable, '-I', '-S', '-c', 'import time; time.sleep(60)'], + stdin=sys.stdin, stdout=sys.stdout, stderr=subprocess.DEVNULL) +(root/'child.pending').write_text(str(child.pid)) +(root/'child.pending').replace(root/'child.pid') +while not (root/'observed').exists(): time.sleep(.01) +while sys.stdin.buffer.read(1) not in (bytes([0]), bytes([10]), b''): pass +reply = b'a'*40+b' blob 4\n\x00\xff\r\n\n' +prefix = {'header': b'', 'payload': reply[:-3], 'terminator': reply[:-1]}.get(mode, reply) +sys.stdout.buffer.write(prefix) +sys.stdout.buffer.flush() +os._exit(0) +''' + + +class ProcessHandle: + def __init__(self, pid): + self.api = ctypes.WinDLL('kernel32', use_last_error=True) + for name, result, args in ( + ('OpenProcess', wintypes.HANDLE, [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]), + ('WaitForSingleObject', wintypes.DWORD, [wintypes.HANDLE, wintypes.DWORD]), + ('TerminateProcess', wintypes.BOOL, [wintypes.HANDLE, wintypes.UINT]), + ('CloseHandle', wintypes.BOOL, [wintypes.HANDLE]), + ): + fn = getattr(self.api, name) + fn.restype, fn.argtypes = result, args + self.handle = self.api.OpenProcess(0x00100001, False, pid) + if not self.handle: + raise ctypes.WinError(ctypes.get_last_error()) + + def exited(self, timeout=0): + result = self.api.WaitForSingleObject(self.handle, timeout) + if result not in (0, 258): + raise ctypes.WinError(ctypes.get_last_error()) + return result == 0 + + def terminate(self): + if not self.exited(): + if not self.api.TerminateProcess(self.handle, 1): + raise ctypes.WinError(ctypes.get_last_error()) + if not self.exited(5000): + raise RuntimeError('fixture descendant did not terminate') + + def close(self): + self.terminate() + if not self.api.CloseHandle(self.handle): + raise ctypes.WinError(ctypes.get_last_error()) + + +@unittest.skipUnless(os.name == 'nt', 'native Windows Job Objects required') +class WindowsGitReaderTests(unittest.TestCase): + def test_direct_child_mutation_is_rejected(self): + # Replacing the job owner with direct Popen must fail all six boundaries. + # The inner fixture still cleans its children after recording failures. + case = WindowsGitReaderTests('test_descendants_and_consumer_pause_at_the_shared_reader') + result = unittest.TestResult() + with patch.object(PACKAGE, '_git_blob_popen', subprocess.Popen): + case.run(result) + self.assertEqual(len(result.failures), 6) + self.assertEqual(result.errors, []) + + def test_descendants_and_consumer_pause_at_the_shared_reader(self): + for mode in ('success', 'header', 'payload', 'terminator', 'early-close', 'consumer-error'): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + real_factory = PACKAGE._git_blob_popen + handles, gates, guards = [], [], [] + guard_fired = threading.Event() + + def start(argv, **kwargs): + process = real_factory( + [sys.executable, '-I', '-S', '-c', PRODUCER, str(root), mode], **kwargs) + gates.append(process) + deadline = time.monotonic() + 5 + while not (root / 'child.pid').exists(): + if time.monotonic() >= deadline: + process.__exit__(None, None, None) + self.fail('descendant readiness timed out') + time.sleep(.01) + handle = ProcessHandle(int((root / 'child.pid').read_text())) + handles.append(handle) + self.assertFalse(handle.exited()) + + def guard(): + guard_fired.set() + handle.terminate() + timer = threading.Timer(4, guard) + guards.append(timer) + timer.start() + (root / 'observed').touch() + return process + + try: + with (patch.object(PACKAGE, '_git_blob_popen', side_effect=start), + patch.object(PACKAGE, 'GIT_BLOB_RESPONSE_TIMEOUT', .25), + closing(PACKAGE.git_blobs(root, ['HEAD:x'])) as rows): + if mode in ('header', 'payload', 'terminator'): + with self.assertRaises(subprocess.TimeoutExpired): + list(rows) + elif mode == 'success': + self.assertEqual(list(rows), [('a' * 40, b'\0\xff\r\n')]) + else: + self.assertEqual(next(rows), ('a' * 40, b'\0\xff\r\n')) + time.sleep(.4) # Consumer time is outside the response deadline. + self.assertFalse(handles[0].exited()) + if mode == 'consumer-error': + with self.assertRaisesRegex(ValueError, 'validation failed'): + with closing(rows): + raise ValueError('validation failed') + else: + rows.close() + self.assertFalse(guard_fired.is_set(), 'fallback teardown rescued the reader') + self.assertEqual(len(handles), 1) + self.assertTrue(handles[0].exited(1000), 'live descendant before teardown') + self.assertTrue(all(g.poll() is not None for g in gates)) + self.assertTrue(all(g.stdin.closed and g.stdout.closed for g in gates)) + finally: + for timer in guards: + timer.cancel() + timer.join() + for handle in handles: + handle.close() + for gate in gates: + gate.__exit__(None, None, None) + + def test_native_launch_errors_do_not_become_blob_errors_or_retry(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + invalid = root / 'invalid.exe' + invalid.write_bytes(b'not an executable') + for executable in (root / 'missing.exe', invalid, root): + with self.subTest(executable=executable.name): + argv = [str(executable)] + with self.assertRaises(OSError) as direct: + subprocess.Popen(argv) + real_factory = PACKAGE._git_blob_popen + with (patch.object(PACKAGE, '_git_blob_popen', + side_effect=lambda ignored, **kw: real_factory(argv, **kw)) as launch, + self.assertRaises(OSError) as wrapped): + list(PACKAGE.git_blobs(root, ['HEAD:missing'])) + fields = ('errno', 'winerror', 'strerror', 'filename', 'filename2') + self.assertEqual(type(wrapped.exception), type(direct.exception)) + self.assertEqual([getattr(wrapped.exception, f) for f in fields], + [getattr(direct.exception, f) for f in fields]) + self.assertEqual(launch.call_count, 1) + + def test_unicode_argv_environment_cwd_and_binary_output(self): + with tempfile.TemporaryDirectory(prefix='git-\u03ba-') as temporary: + root = Path(temporary) + payload = b'\0\xff\r\n' * 20000 + code = ('import os,sys; from pathlib import Path; ' + 'assert sys.argv[1] == "\u03ba"; assert os.environ["GIT_TEST_VALUE"] == "\u03ba"; ' + 'assert Path.cwd() == Path(sys.argv[2]); ' + 'sys.stdin.buffer.read(1); sys.stdout.buffer.write(b"\\0\\xff\\r\\n"*20000)') + with PACKAGE._git_blob_popen( + [sys.executable, '-I', '-S', '-c', code, '\u03ba', str(root)], cwd=root, + env=dict(os.environ, GIT_TEST_VALUE='\u03ba'), stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) as process: + process.stdin.write(b'x') + process.stdin.close() + self.assertEqual(process.stdout.read(), payload) + self.assertEqual(process.wait(timeout=5), 0) + + def test_launched_exit_129_retries_once_with_native_owner(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + subprocess.run(['git', 'init', '-q', str(root)], check=True, capture_output=True) + payload = b'\0\xff\r\n' * 20000 + oid = subprocess.run(['git', 'hash-object', '-w', '--stdin'], cwd=root, + input=payload, capture_output=True, check=True).stdout.decode().strip() + for status in (0, 1, 7, 129): + with self.subTest(status=status): + real_factory = PACKAGE._git_blob_popen + calls = [] + + def start(argv, **kwargs): + calls.append(argv) + command = ([sys.executable, '-I', '-S', '-c', f'raise SystemExit({status})'] + if len(calls) == 1 else argv) + return real_factory(command, **kwargs) + + with patch.object(PACKAGE, '_git_blob_popen', side_effect=start): + if status == 129: + self.assertEqual(list(PACKAGE.git_blobs(root, [oid, oid])), + [(oid, payload), (oid, payload)]) + else: + with self.assertRaisesRegex(ValueError, 'missing or non-blob'): + list(PACKAGE.git_blobs(root, [oid])) + expected = [['git', 'cat-file', '--batch', '-z']] + if status == 129: + expected.append(['git', 'cat-file', '--batch']) + self.assertEqual(calls, expected) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_research_head_pin_paths.py b/tests/test_research_head_pin_paths.py index 6287d6d017..b040a90274 100644 --- a/tests/test_research_head_pin_paths.py +++ b/tests/test_research_head_pin_paths.py @@ -27,7 +27,7 @@ def fixture(self, name): self.package = 'research/fixture' self.name = name (self.root / 'scripts').mkdir() - for script in ('research_package.py', 'check_research_commit.py'): + for script in ('research_package.py', 'check_research_commit.py', 'research_windows_git.py'): shutil.copyfile(ROOT / 'scripts' / script, self.root / 'scripts' / script) self.card = {'id': 'fixture', 'question': 'Check exact source paths'} self.catalog = 'docs/research/agent-task-catalog.json' diff --git a/tests/test_research_input_paths.py b/tests/test_research_input_paths.py index e8fcf5a183..670a886251 100644 --- a/tests/test_research_input_paths.py +++ b/tests/test_research_input_paths.py @@ -145,6 +145,9 @@ def git(*args): (self.root / "scripts/research_package.py").write_bytes( (ROOT / "scripts/research_package.py").read_bytes() ) + (self.root / "scripts/research_windows_git.py").write_bytes( + (ROOT / "scripts/research_windows_git.py").read_bytes() + ) self.write_manifest("./fixtures/input.txt") git("add", ".") git("-c", "gc.auto=0", "commit", "-qm", "before") diff --git a/tests/test_research_onboarding.py b/tests/test_research_onboarding.py index 3eeec180bf..9df3aa45a2 100644 --- a/tests/test_research_onboarding.py +++ b/tests/test_research_onboarding.py @@ -388,6 +388,7 @@ def test_cli_replay_cannot_accept_assertion_failure_from_environment(self): scripts.mkdir() runner = scripts / "research_package.py" runner.write_bytes((ROOT / "scripts/research_package.py").read_bytes()) + (scripts / "research_windows_git.py").write_bytes((ROOT / "scripts/research_windows_git.py").read_bytes()) verifier = self.target / "verify.py" argv = [sys.executable, "-B", str(runner), "--package", "research/fixture"] for value in ("", "0", "1", "2"): @@ -414,6 +415,7 @@ def test_cli_unicode_coverage_survives_legacy_stdout(self): scripts.mkdir() runner = scripts / "research_package.py" runner.write_bytes((ROOT / "scripts/research_package.py").read_bytes()) + (scripts / "research_windows_git.py").write_bytes((ROOT / "scripts/research_windows_git.py").read_bytes()) verifier = self.target / "verify.py" argv = [sys.executable, "-B", str(runner), "--package", "research/fixture"] for encoding in ("cp1252", "ascii", "utf-8"): @@ -444,6 +446,7 @@ def test_cli_unicode_coverage_does_not_relax_source_pins(self): scripts.mkdir() runner = scripts / "research_package.py" runner.write_bytes((ROOT / "scripts/research_package.py").read_bytes()) + (scripts / "research_windows_git.py").write_bytes((ROOT / "scripts/research_windows_git.py").read_bytes()) (self.root / "input.txt").write_text("changed input\n", encoding="utf-8") (self.target / "verify.py").write_text('print("UNEXPECTED_CHILD")\n', encoding="utf-8") result = subprocess.run([sys.executable, "-B", str(runner), "--package", "research/fixture"], diff --git a/tests/test_research_package_diagnostics.py b/tests/test_research_package_diagnostics.py index 170de56a03..2ce405c71d 100644 --- a/tests/test_research_package_diagnostics.py +++ b/tests/test_research_package_diagnostics.py @@ -47,7 +47,7 @@ def setUp(self): self.base = package.git(self.root, "rev-parse", "HEAD").decode().strip() scripts = self.root / "scripts" scripts.mkdir() - for name in ("research_package.py", "new_research_package.py"): + for name in ("research_package.py", "research_windows_git.py", "new_research_package.py"): shutil.copy(ROOT / "scripts" / name, scripts / name) self.target = scaffold.create(self.root, "fixture", "fixture", "Fixture", ["input.txt"]) self.ready() diff --git a/tests/test_research_replay_selection.py b/tests/test_research_replay_selection.py index 9385a323ff..c1ca036887 100644 --- a/tests/test_research_replay_selection.py +++ b/tests/test_research_replay_selection.py @@ -29,7 +29,7 @@ def setUp(self): for key, value in (("user.name", "Fixture"), ("user.email", "fixture@example.invalid"), ("core.autocrlf", "false"), ("diff.renames", "true")): self.git("config", key, value) - for name in ("scripts/research_package.py", WORKFLOW): + for name in ("scripts/research_package.py", "scripts/research_windows_git.py", WORKFLOW): target = self.root / name target.parent.mkdir(parents=True, exist_ok=True) target.write_bytes((ROOT / name).read_bytes()) @@ -92,7 +92,7 @@ def test_renamed_input_still_selects_its_old_consumer(self): self.assertEqual(self.selected(base), ["research/alpha"]) def test_global_controls_select_all_registered_packages(self): - for name in (WORKFLOW, "scripts/research_package.py"): + for name in (WORKFLOW, "scripts/research_package.py", "scripts/research_windows_git.py"): with self.subTest(name=name): base = self.git("rev-parse", "HEAD") with (self.root / name).open("a", encoding="utf-8") as stream: diff --git a/tests/test_research_scaffold_batch.py b/tests/test_research_scaffold_batch.py index ff25ec92da..786675f202 100644 --- a/tests/test_research_scaffold_batch.py +++ b/tests/test_research_scaffold_batch.py @@ -45,14 +45,22 @@ def create(self, slug='fixture', sources=None): def test_exact_pins_binary_names_deduplication_and_constant_process_count(self): children, commands = [], [] - real = subprocess.Popen + real, real_git = package._git_blob_popen, scaffold.git def tracked(argv, *args, **kwargs): child = real(argv, *args, **kwargs) children.append(child); commands.append(argv) return child - with patch.object(subprocess, 'Popen', side_effect=tracked): + def metadata(root, *args): + commands.append(['git', *args]) + return real_git(root, *args) + with (patch.object(package, '_git_blob_popen', side_effect=tracked), + patch.object(scaffold, 'git', side_effect=metadata)): target = self.create(sources=self.inputs + self.inputs[:3]) + # One stream serves the entire source inventory; platform ownership may + # add a gate but must not launch one Git process per source. self.assertEqual(len(commands), 3, commands) + self.assertEqual([cmd for cmd in commands if cmd[1] == 'cat-file'], + [["git", "cat-file", "--batch", "-z"]]) self.assertTrue(all(child.poll() is not None for child in children)) rows = json.loads((target / 'SOURCES.json').read_bytes())['files'] expected = [] @@ -66,7 +74,7 @@ def tracked(argv, *args, **kwargs): {'TASK.json', 'SOURCES.json', 'VERIFY.json', 'verify.py', 'README.md'}) def test_live_drift_rejects_first_and_last_inputs_without_draft_or_child_leak(self): - real = subprocess.Popen + real = package._git_blob_popen for index, name in enumerate((self.inputs[0], self.inputs[-1])): with self.subTest(name=name): path = self.root / name @@ -74,7 +82,7 @@ def test_live_drift_rejects_first_and_last_inputs_without_draft_or_child_leak(se children = [] def tracked(*args, **kwargs): child = real(*args, **kwargs); children.append(child); return child - with patch.object(subprocess, 'Popen', side_effect=tracked): + with patch.object(package, '_git_blob_popen', side_effect=tracked): with self.assertRaisesRegex(ValueError, 'source has uncommitted changes'): self.create('drift-' + str(index)) self.assertFalse((self.root / 'research' / ('drift-' + str(index))).exists()) diff --git a/tests/test_research_source_history.py b/tests/test_research_source_history.py index aaaa47c607..65e6a15f59 100644 --- a/tests/test_research_source_history.py +++ b/tests/test_research_source_history.py @@ -219,7 +219,7 @@ def test_metadata_shape_and_resource_bounds(self): self.check() def test_real_cli_is_read_only_and_reports_no_pass_on_history_failure(self): - for name in ('check_research_commit.py', 'research_package.py'): + for name in ('check_research_commit.py', 'research_package.py', 'research_windows_git.py'): destination = self.root / 'scripts' / name destination.parent.mkdir(exist_ok=True) destination.write_bytes((ROOT / 'scripts' / name).read_bytes()) diff --git a/tests/test_research_sparse_planning.py b/tests/test_research_sparse_planning.py index 9ef82e601e..ff065229e5 100644 --- a/tests/test_research_sparse_planning.py +++ b/tests/test_research_sparse_planning.py @@ -19,6 +19,7 @@ def setUp(self): self.git('init','-q');self.git('config','user.name','Fixture') self.git('config','user.email','fixture@example.invalid');self.git('config','core.autocrlf','false') self.write('scripts/research_package.py',(ROOT/'scripts/research_package.py').read_text()) + self.write('scripts/research_windows_git.py',(ROOT/'scripts/research_windows_git.py').read_text()) self.write('inputs/value.txt','1') for name in ['alpha','hidden']: self.write('research/'+name+'/VERIFY.json',json.dumps({'inputs':['inputs/value.txt']})) @@ -57,6 +58,7 @@ def test_full_and_sparse_matrices_match_for_input_and_global_changes(self): self.assertEqual(actual.stdout,expected.stdout) self.assertIn('1 unchanged sparse manifests',actual.stderr) self.assertEqual(runner.plan(self.root,['scripts/research_package.py']),['research/alpha','research/hidden']) + self.assertEqual(runner.plan(self.root,['scripts/research_windows_git.py']),['research/alpha','research/hidden']) self.assertFalse((self.root/self.manifest).exists()) def test_explanations_bind_identical_manifest_bytes_in_sparse_checkout(self): @@ -132,8 +134,11 @@ def test_working_edits_and_new_local_manifests_remain_visible(self): def test_nonregular_head_manifest_is_never_a_sparse_json_input(self): (self.root/self.manifest).unlink() - (self.root/self.manifest).symlink_to('README.md') - self.commit();self.sparse() + # This is a Git-mode check. Build the tree entry directly so Windows + # does not need permission to materialize a filesystem symlink. + blob=self.git('hash-object','-w','--stdin',input=b'README.md').decode().strip() + self.git('update-index','--cacheinfo','120000,'+blob+','+self.manifest) + self.git('commit','-qm','symlink manifest');self.sparse() result=self.cli();self.assertNotEqual(result.returncode,0);self.assertEqual(result.stdout,'') diff --git a/tests/test_research_symlink_inputs.py b/tests/test_research_symlink_inputs.py index d0c4cce7ae..6a33c00e62 100644 --- a/tests/test_research_symlink_inputs.py +++ b/tests/test_research_symlink_inputs.py @@ -40,6 +40,7 @@ def setUp(self): }.items(): self.write(name, text) self.write("scripts/research_package.py", (ROOT / "scripts/research_package.py").read_text()) + self.write("scripts/research_windows_git.py", (ROOT / "scripts/research_windows_git.py").read_text()) self.commit() self.target = scaffold.create(self.root, "alpha", "fixture", "Fixture") task_path = self.target / "TASK.json"