Skip to content

fix: guard FDCapture.snap() against prematurely closed tmpfile #6

fix: guard FDCapture.snap() against prematurely closed tmpfile

fix: guard FDCapture.snap() against prematurely closed tmpfile #6

name: "validate #14528 (Windows capture teardown crash)"
on:
push:
branches:
- "fix/14528-*"
pull_request:
branches:
- main
permissions: {}
jobs:
reproduce:
name: "validate-14528 (windows-py314)"
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.14.3"
allow-prereleases: true
- name: Install pytest from this branch
run: pip install -e .
- name: Verify setup
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { execSync } = require('child_process');
const checks = [
'python --version',
'python -m pytest --version',
'python -c "import sys; print(sys.platform, sys.version)"',
];
for (const cmd of checks) {
const out = execSync(cmd, { encoding: 'utf-8' }).trim();
core.info(`${cmd} → ${out}`);
}
- name: Create reproducer tree and run diagnostics
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
// Create reproducer tree matching issue #14528
const base = path.join('.claude', 'skills', 'actionmail', 'tests');
fs.mkdirSync(path.join(base, 'fixtures'), { recursive: true });
fs.writeFileSync(path.join(base, '__init__.py'), '');
fs.writeFileSync(path.join(base, 'fixtures', 'data.json'), '{"key": "value"}');
for (const name of ['test_v3_heuristics.py', 'test_v3_integration.py']) {
fs.writeFileSync(path.join(base, name), [
'import sys, pathlib',
'SKILL_DIR = str(pathlib.Path(__file__).parent.parent)',
'sys.path.insert(0, SKILL_DIR)',
'import run',
`def test_${name.replace('.py', '')}(): pass`,
].join('\n'));
}
core.info(`Reproducer tree: ${path.resolve(base)}`);
// Artifacts directory
const artifacts = 'trace-artifacts';
fs.mkdirSync(artifacts, { recursive: true });
// Helper: run a command, capture output, save to file, log exit code
function run(label, cmd, env = {}) {
const outFile = path.join(artifacts, label + '.txt');
core.info(`--- ${label} ---`);
core.info(`cmd: ${cmd}`);
let stdout, exitCode;
try {
stdout = execSync(cmd, {
encoding: 'utf-8',
env: { ...process.env, ...env },
timeout: 60_000,
});
exitCode = 0;
} catch (err) {
stdout = (err.stdout || '') + '\n--- STDERR ---\n' + (err.stderr || '');
exitCode = err.status;
}
const content = `# ${label}\n# cmd: ${cmd}\n# exit_code: ${exitCode}\n\n${stdout}`;
fs.writeFileSync(outFile, content);
core.info(`exit_code=${exitCode} saved=${outFile}`);
return { stdout, exitCode };
}
const target = '.claude/skills/actionmail/tests/';
// 1. Normal pytest run (the actual issue scenario)
run('01-pytest-normal',
`python -m pytest ${target} -v --tb=long`);
// 2. pytest with --collect-only
run('02-pytest-collect-only',
`python -m pytest ${target} --collect-only -v --tb=long`);
// 3. pytest with capture disabled (reporter says this suppresses crash)
run('03-pytest-no-capture',
`python -m pytest ${target} -v -s --tb=long`);
// 4. Python -v for import tracing during pytest
run('04-pytest-python-verbose',
`python -v -m pytest ${target} -v --tb=long`);
// 5. Python with faulthandler and GC debug
run('05-pytest-gc-debug',
`python -c "import gc; gc.set_debug(gc.DEBUG_STATS); import pytest; pytest.main(['${target.replace(/\\/g, '/')}', '-v', '--tb=long'])"`);
// 6. Python version and GC info
run('06-python-info',
`python -c "import sys, gc; print('version:', sys.version); print('platform:', sys.platform); print('gc.get_threshold:', gc.get_threshold()); print('gc.get_stats:', gc.get_stats())"`);
// 7. Trace EncodedFile.close() calls via monkeypatch
const traceScript = `
import os, sys, traceback
from _pytest.capture import EncodedFile
_orig_close = EncodedFile.close
_trace_fd = os.open('trace-artifacts/07-close-trace-raw.txt', os.O_WRONLY | os.O_CREAT | os.O_TRUNC)
def _traced_close(self):
msg = (
f"\\n=== EncodedFile.close() closed={self.closed} ===\\n"
+ "".join(traceback.format_stack())
)
os.write(_trace_fd, msg.encode())
return _orig_close(self)
EncodedFile.close = _traced_close
import pytest
code = pytest.main(['${target.replace(/\\/g, '/')}', '-v', '--tb=long'])
os.close(_trace_fd)
sys.exit(code)
`;
fs.writeFileSync('_trace_close.py', traceScript);
run('07-pytest-traced-close',
'python _trace_close.py');
- name: Upload trace artifacts
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: issue-14528-traces
path: trace-artifacts/
retention-days: 30