Skip to content

SEP-1934: Tolerate concurrent scaffold churn in the import-boundary walk - #1439

Merged
marcuscruz-percona merged 5 commits into
mainfrom
SEP-1934
Sep 3, 2026
Merged

SEP-1934: Tolerate concurrent scaffold churn in the import-boundary walk#1439
marcuscruz-percona merged 5 commits into
mainfrom
SEP-1934

Conversation

@marcuscruz-percona

@marcuscruz-percona marcuscruz-percona commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Make the import-boundary guards tolerate the scaffold tests' concurrent churn: every disk walk now goes through one parse helper that catches FileNotFoundError only, so a path that vanished between listing and reading is treated as "not a module to check" instead of failing the run with an error that says nothing about a real boundary violation. A decode or parse failure still raises, so the guard cannot silently narrow to whatever happens to read cleanly. No name-based skip: scaffold package names are not uniformly underscore-prefixed, the same prefix would also match app/sep/apps/__init__.py, and a name skip would leave a real module unchecked.
  • Close two racy sites the ticket did not name. The orchestrator scan had its own rglob + read_text inline in the test body, now extracted into a collector on the same helper; the apps-tree coverage assert compared a fresh walk against a stale module-scoped fixture, and is now ordering-safe (narrow walk sampled first, wider walk second, non-existent paths dropped), so neither half of a scaffold test's churn can fail it.
  • Give the import-time collector the same injectable-modules seam the deferred collector already had. That removes a duplicated read-and-parse loop and lets test_no_module_imports_another_app_package's rule be driven over a synthetic violating tree — previously it could only ever be asserted against a green one, so it passed vacuously.
  • Tolerate the walk too, not only the read. The read-level fix alone closes the race on 3.13, where the glob machinery happens to swallow OSError while recursing, but CI runs 3.11.9 and _RecursiveWildcardSelector._iterate_directories there guards its scandir with except PermissionError alone — so a package removed after it was listed aborts the walk before any read happens. Every walk now goes through one os.walk-based collector whose onerror handler skips a directory that is gone and re-raises anything else; a directory we may not read still fails loudly. Symlinked directories stay unwalked, matching the previous behaviour.
  • Test-only; the scaffolder and test_scaffold.py are unchanged, and no scheduler setting moved.

Tested

Baseline first, on the unfixed file: 10 rounds of the two racing files (5 loadscope + 5 loadfile) produced 22 FileNotFoundErrors across 9 failing rounds, hitting all four sites — including the orchestrator scan and the coverage assert.

The 3.11 window is reproduced directly: running that interpreter's algorithm verbatim under a scandir wrapper that removes the doomed directory right after the parent is listed raises FileNotFoundError: [Errno 2] No such file or directory: .../doomed, while the replacement walker completes.

Negative control: strip the tolerant read and keep the new tests, and 6 of them fail with exactly the reported FileNotFoundError; restore it and they pass. They are guarding something.

  • make test (worksteal, the adopted scheduler): 11361 passed, 434 skipped, 0 FileNotFoundError
  • 20 high-pressure rounds of test_import_boundary.py + apps/framework/test_scaffold.py (10 loadscope, 10 loadfile): 20/20 exit 0, 0 FileNotFoundError
  • Full tests/app/sep under loadscope and again under loadfile: 6924 passed, 387 skipped each, 0 FileNotFoundError
  • Focused tests/app/sep/test_import_boundary.py: 85 passed
  • make lint: clean

Repeated green runs are evidence, not proof — this race is latent under worksteal rather than closed by it, which is why the fix targets the observer instead of the scheduler. The remaining consecutive-run evidence is cheap to add if a reviewer wants it.

Checklist

  • New/modified functions have type hints and rST docstrings
  • New tests added for new features or bug fixes
  • All tests pass locally (make test)
  • Pre-commit hooks pass (make run-pre-commit)
  • Database migrations generated if models changed (make makemigrations) — N/A, no model change
  • User-facing changes documented (README, inline help, UI text) — N/A, test-only
  • Configuration changes documented with examples — N/A
  • Changelog fragment added under changelog.d/ if the change is user-facing (make changelog-add), or confirmed N/A (internal-only change) — N/A, test-only

The scaffolder writes real packages into app/sep/apps/ by design, and the
scaffold tests remove them in teardown. Under xdist those tests can land on a
different worker than the import-boundary guards, which walk the same tree, so
a path listed a moment earlier can be gone by the time it is read. The guards
then failed with a FileNotFoundError that said nothing about a real boundary
violation.

Route every disk walk through one tolerant parse helper that catches
FileNotFoundError only: a path that no longer exists is not a module to check,
while a decode or parse failure still raises rather than silently narrowing the
guard. Give the import-time collector the same injectable-modules seam the
deferred collector already had, which removes a duplicated read-and-parse loop
and lets the rule be driven over a synthetic violating tree instead of only a
green one. Extract the orchestrator scan into its own collector on the same
helper, and make the apps-tree coverage comparison ordering-safe by sampling
the narrow walk first, the wider one second, and dropping paths that no longer
exist.

No name-based skip: scaffold package names are not uniformly underscore-
prefixed, the same prefix would also match app/sep/apps/__init__.py, and a name
skip would leave a real module unchecked.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The rglob() traversal can still raise when a scaffold directory disappears before parsing begins.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Hardens import-boundary tests against concurrent scaffold creation and removal.

Changes:

  • Centralizes tolerant module parsing.
  • Adds synthetic violation and race regression tests.
  • Makes coverage and orchestrator scans ordering-safe.
File summaries
File Description
tests/app/sep/test_import_boundary.py Adds race-tolerant collectors and regression coverage.
Review details

Suppressed comments (3)

tests/app/sep/test_import_boundary.py:504

  • Use em dashes in this rendered prose instead of the two literal -- sequences.
    collector, so the rule the live-tree case asserts vacuously -- a green tree yields
    an empty list either way -- is pinned against a tree that does violate it. The

tests/app/sep/test_import_boundary.py:754

  • Use an em dash here; -- is rendered as two literal hyphens in the docstring.
    real module behind it -- which is what the concurrent-scaffold failure looked like.

tests/app/sep/test_import_boundary.py:376

  • Use an em dash here; -- remains two literal hyphens in rendered documentation.
        gone -- a permission failure, say.
  • Files reviewed: 1/1 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread tests/app/sep/test_import_boundary.py Outdated
Comment thread tests/app/sep/test_import_boundary.py Outdated
Comment thread tests/app/sep/test_import_boundary.py Outdated
…ished read

The tolerant read closes the race only on Python 3.13, where the glob machinery
happens to swallow OSError while recursing. This project supports 3.11.9 and up
and CI runs 3.11.9, whose _RecursiveWildcardSelector._iterate_directories
guards its scandir with "except PermissionError" alone, so a scaffold package
removed after it was listed but before the descent reaches it aborts the whole
walk before any read happens.

Route every walk through one os.walk-based collector whose onerror handler
skips a directory that is gone and re-raises anything else, so the tolerance no
longer depends on which interpreter runs the suite. Symlinked directories stay
unwalked, matching the previous behaviour.

Also correct the dangling-symlink case's docstring: the scaffold test it cited
symlinks the app directory, not a module, so the claim that a walk yields that
exact shape was wrong. The case still pins what it always pinned, that a read
behind a link with no target is tolerated. Use em dashes in the prose the file
adds, matching the rest of the tree.
@marcuscruz-percona marcuscruz-percona added the qa passed Tests for this PR are completed and successful. label Sep 1, 2026
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

Coverage report

This PR does not seem to contain any modification to coverable code.

@marcuscruz-percona
marcuscruz-percona enabled auto-merge (squash) September 3, 2026 12:24
@marcuscruz-percona
marcuscruz-percona merged commit 0a143e9 into main Sep 3, 2026
18 checks passed
@marcuscruz-percona
marcuscruz-percona deleted the SEP-1934 branch September 3, 2026 12:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python qa passed Tests for this PR are completed and successful.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants