| Created | 2026-09-19 |
|---|---|
| Last-Modified | 2026-09-21 |
| SPDX-FileCopyrightText | 2026-present Arthit Suriyawongkul |
| SPDX-FileType | DOCUMENTATION |
| SPDX-License-Identifier | CC0-1.0 |
See also: non-hatchling-file-discovery.md
(the build-and-read mechanism this bounds, and the roadmap item this
closes), allow-build-termination.md
(SIGTERM/SIGHUP/SIGBREAK handling around the build and its result),
allow-build-validation.md
(real-world --allow-build validation this change doesn't invalidate --
same success/fallback outcomes, now with a bound on how long either can
take).
Roadmap: 1.0 table row 5, previously tracked under "Medium-term" in roadmap.md.
--allow-build's real PEP 517 build (build_and_read_wheel() in
src/pitloom/core/_models_wheel_build_and_read.py) ran in-process
via PyPA build's ProjectBuilder/DefaultIsolatedEnv API, which has
no timeout anywhere in its call chain
(pyproject_hooks.default_subprocess_runner is a bare check_call;
env.install() is build._ctx.run_subprocess, also untimed). A hung
build -- a slow or broken network fetch for build-system.requires, a
build backend blocking on stdin, a misbehaving build script -- blocked
the whole loom project/generate/embed-wheel invocation
indefinitely, with no escape but Ctrl-C. A user who opted into
--allow-build opted into running third-party build-time code, not
into an unbounded hang.
- New
--build-timeout DURATIONflag/BuildOptionsfield/Action input. CLI and Action accept a duration string (bare integer = seconds, orh/m/sunits); the library API stays plainintseconds -- Python callers already have90 * 60, a string parser would be friction, not help. See "Duration grammar" below. - Default when omitted: 1200 s (20 minutes). The flag may set any valid value above or below the default -- there's no "recommended minimum" enforced beyond the general 1 s floor.
- Valid range: 1..604800 (7 days). No "0 = unlimited" -- an unbounded hang is the exact bug being fixed; a caller who wants a very long bound passes a very large number instead.
- On timeout: kill the whole build process tree, log a
WARNING:, and fall back to static discovery -- the existing "build-and-read returnedNone" path, already wired to the Hatchling-heuristic fallback. No change needed in the fallback logic itself; a timeout is just one more waybuild_and_read_wheel()can come back empty. - No
[tool.pitloom]config-file key, matching--allow-builditself -- only meaningful alongside--allow-build, and a scanned project's ownpyproject.tomlmust never be able to steer its own scanner's security-relevant behaviour. CLI flag,BuildOptions.timeout(library API andConfigOverrides), and GitHub Action input only. - Two more intentional behaviour changes, bundled into this change
because they use the same subprocess plumbing: the build's own
stdout/stderr no longer leak onto Pitloom's stdout/stderr (previously
inherited via
check_call); they're captured to a log file instead, shown atDEBUG:on failure. The build's stdin is closed (equivalent of/dev/null), so a backend that waits on stdin gets EOF immediately instead of hanging -- this was actually a second, narrower instance of the same "unbounded hang" bug, worth closing in the same change rather than filing separately. - SIGTERM/SIGHUP (and Windows Ctrl-Break, SIGBREAK) to Pitloom kill the build tree and remove the temp dirs before Pitloom exits by the same signal -- during the build and for as long as its extracted files are in use. See allow-build-termination.md.
The first cut threaded allow_build/no_build_isolation/build_timeout
as three kwargs through every layer (generate() ->
generate_project_sbom() -> get_wheel_files(), ConfigOverrides ->
_build_sbom_from_project_and_wheel() -> get_wheel_files()). That
left three problems:
- The "flag given without
--allow-build" warning lived only in the CLI (warn_if_build_flags_without_allow_build()), so a library caller passingbuild_timeout=900alone to a project directory got no warning at all -- a silent no-op. - For a target that never reaches file discovery (sdist, wheel, model,
embed-wheel --sbom, ...), the CLI's stray warning and the library's "no effect for this target" warning both fired: two lines for one flag. - Timeout validation was repeated at four public entry points, and the "any flag given" check was hand-copied at four warning sites.
Fix: one frozen dataclass,
pitloom.core.build_options.BuildOptions(allow, no_isolation, timeout),
is the only form every surface takes (build_options= on generate(),
generate_project_sbom(), get_wheel_files(); a
ConfigOverrides.build_options field; the CLI builds it once with
build_options_from_args()). Changing the public signature was allowed
(private alpha, no backward-compat need).
- Validation happens once, in
__post_init__, so an invalid value can't reach any surface, whatever the target. It also requiresallow/no_isolationto be realbools: with the old kwargs,allow_build="false"was truthy and started a real build. - Warnings come from three settle methods on
BuildOptions.settle(subject)runs at every surface that does reach file discovery, as early as that surface can tell (before any project-metadata or lock-file read) -- CLI command handlers first, then again (idempotently, since a settled value has nothing left to warn about) insidegenerate_project_sbom(),embed_wheel_sbom(), and finallyget_wheel_files()itself for a direct caller that skipped every earlier layer.settle_not_applicable(subject, reason)is its mirror for a target a caller already knows will never reach file discovery (an sdist archive, a non-project target, an externally-supplied--sbom): it warns with a target-specific reason and resets to defaults in one call.settle_target(path)picks between the two for a project-or-sdist path: an sdist archive (pitloom.core.project.is_sdist_archive(), an extension match on an existing file) gets the sdist reason, a directory getssettle, and anything else -- a missing path, or a file that is no archive -- settles nothing, so the read fails with an ERROR alone. Shared byloom project,loom generateandgenerate_project_sbom(). Every run path reaches exactly one warning per given flag, so each ignored flag gets exactly oneWARNING: Build: <subject>: <flag> has no effect <reason>line on every surface, and it's always the first thing that surface logs. One line per flag, not one combined line, so a grep for one flag finds it. get_wheel_files()is the right place for the stray check: the Hatchling hook and_model_generator.pycall it with the default (no flags given), so they stay silent.
Side effects, accepted:
- An
embed-wheelbatch warns once, not once per wheel, whatever the project source (--project-dir, cwd,--sbom, or none):EmbedFileCache.settle()settles once perwithblock for each distinct (options, reason) pair and reuses that result for every later wheel -- so a library batch sharing a publicEmbedFileCachegets the same. Keyed, not a single memo: a later call's own options (e.g.allow=Trueafter a--sbomcall) must never be replaced by the first call's settled result.resolve()raisesValueErrorwhen a later call asks for another project directory, file-scan settings or build options than its cached file list was resolved with, rather than silently returning a list that doesn't match. The CLI handler also settles up front, for warning order:--sbomand--project-dirbefore its own config read, an implicit cwd right after it (that read is what tells whether cwd is a project). - A target path that doesn't exist settles nothing (
generate_project_sbom()settles an existing file or directory only;embed-wheelsettles an explicit--project-dironly when it is a directory), soloom project,loom generate,loom embed-wheel --project-dirand the library all fail it with the oneERROR:/FileNotFoundError, and no build-flag warning about a target that was never there. generate()now callsconfigure_logging()first; before, its own "no effect" lines could reach stderr without theWARNING:prefix.- An sdist archive target used to show its build-flag warning after a
metadata warning from a CLI handler's own pre-read (
loom project <sdist>'s_resolve_project_generation_settings(),loom generate <sdist>'s non-fast-path config-only peek, since removed) or fromgenerate_project_sbom()'s own metadata resolution for a direct library caller. Fixed:BuildOptions.settle_not_applicable(subject, reason)warns (if any flag was given) and resets to defaults in one call, mirroringsettle()'s contract but for a target a caller already knows won't reach file discovery. Both CLI handlers call it -- with the sharedpitloom.core.build_options.SDIST_TARGET_REASONstring -- right after the cheapis_sdist_archive()check that tells them it's an sdist, before either resolves any metadata;generate_project_sbom()calls it too, so a direct library caller gets the same ordering with no CLI layer involved. Idempotent, so whichever layer settles first leaves nothing for a later layer to re-warn about. - An env/wheel/model-file/Hugging-Face target under
loom generategets the same treatment: the handler classifies it withtarget_resolves_to_project()and callssettle_not_applicable()with the sharedNON_PROJECT_TARGET_REASON(also used bygenerate()) before any metadata is read.
Test matrix: tests/test_build_flag_warnings.py crosses surface (CLI
project/generate/embed-wheel via real argv; library generate()/
generate_project_sbom()/embed_wheel_sbom()/get_wheel_files()) x
target kind (project dir, the CLI's default ., sdist, wheel, env,
model file, Hugging Face, and the embed-wheel project sources
--project-dir/cwd/--sbom/none, each with one wheel and with two
wheels in one batch -- CLI, or a library EmbedFileCache) x all 8
flag combinations. Each case asserts both the exact per-flag warning
count and reason and the build settings that reach file discovery (once
per run, default timeout 1200 s). Every surface x target cell runs
unless _NOT_APPLICABLE names it with a reason, and
test_matrix_accounts_for_every_cell fails on a cell that is neither,
so a new surface or target kind can't go untested by omission. The
Action's inputs have their own row: test_build_input_matrix in
tests/scripts/action/test_generate_step.py (mode x 8 input
combinations). tests/cli/test_cli_build_timeout_process.py runs
loom project --allow-build --build-timeout as a real process (tagged
stderr, exit code, nothing left in the temp dir).
Against the pre-BuildOptions code, 36 of the then 138 cases failed on
count alone (0 lines for library stray flags, 2 lines on the CLI for
non-project targets). The multi-wheel --sbom/no-project/cwd cells,
added later, caught a batch warning once per wheel -- first on the CLI
(13 cases), then, once the library batch cells replaced a wrong "CLI-only"
exclusion, for EmbedFileCache library batches (17 cases).
A thread cannot be killed from outside in CPython -- there is no safe
way to abort a hung ProjectBuilder.build() call running on one.
PyPA build's own runner= hook (the one customization point
ProjectBuilder exposes) only wraps the PEP 517 hook invocations
themselves; it does not cover DefaultIsolatedEnv.install(), which
shells out to pip via build._ctx.run_subprocess on its own,
untouched by any caller-supplied runner. There is no way to inject a
timeout into the existing in-process call graph without patching
build's own internals. Moving the whole build (isolation setup
included) into one external process tree is the only mechanism that
can be reliably killed as a unit, on any platform, from outside.
python -m build is PyPA build's own public CLI, run via
[sys.executable, "-m", "build", ...] so it always resolves against
the same interpreter Pitloom itself runs under. Verified against
build 1.6.1, behaviour is close to -- not identical to -- the old
in-process call:
--wheelbuilds straight from source, the same as before (no sdist built first).- The isolated path installs
build-system.requiresplusget_requires_for_build("wheel"), matching the old_run_pep517_build_wheel-- but the CLI additionally passes pip--ignore-installedandconfig_settings={}, neither of which the old code passed explicitly. --outdiris auto-created if missing (the old code assumed the caller had already made it).- The installer defaults to pip either way.
- Exit code 1 means a build error; exit code 2 means a CLI argument
error -- distinguishable, useful for the
BuildSubprocessErrormessage. --no-isolationmust be paired with--skip-dependency-check, or the CLI refuses outright on missing dependencies -- a check the old bareProjectBuilder(project_dir).build()call never performed at all. Both flags predate thebuild>=1.2.2floor already in place, so no dependency bump was needed.
Each is handled at a commented line in _models_wheel_build_subprocess.py
or _models_wheel_build_kill.py; read that comment before touching the
code.
- Missing
build.find_spec("build")isNone, or a namespace spec (a barebuild/dir onsys.path):RuntimeErrornamingpitloom[build]up front. Notfind_spec("build.__main__"), which imports the package. An emptysys.executableis refused too. - Path length. Short work-dir names (
o/,t/,build.log): amultiprocessingAF_UNIX socket under temp must fit 104 bytes on macOS; WindowsMAX_PATHlikewise. cwd=work_dir.-mputs the cwd onsys.path; a project's ownbuild.pywould shadow PyPAbuildwhen run from its root.- Environment.
TMPDIR/TEMP/TMP->work_dir/t, so the isolated env and pip temp files die with the work dir (ignore_cleanup_errors, a leftover gets aWARNING:);PYTHONUNBUFFERED;NO_COLORand noFORCE_COLOR, so no ANSI residue reaches theWARNING:. stdin=DEVNULL. A backend reading stdin gets EOF, not a hang.- Log file, never
PIPE. A full pipe deadlocks, and a grandchild holding it blockscommunicate()after the kill -- which is also whysubprocess.run(timeout=)(it callscommunicate()on Windows) is out. - Process group. POSIX
start_new_session=True, sokillpg()reaches the tree; Windows nocreationflags(CREATE_NEW_PROCESS_GROUPwould disable Ctrl-C) andtaskkill /T. Two explicitPopencalls: mypy rejects a**kwargsdict and needs the platform narrowing. - Sliced wait (0.25 s). Windows 3.10 doesn't interrupt a long wait
on Ctrl-C, and each slice acts on a recorded signal. The kill runs in
except BaseException, unconditionally: the child can exit at the deadline with grandchildren alive. After a normal exitkill_leftover_descendants()kills what the build left in its group (INFO:;WARNING:if not confirmed gone), reaping Pitloom's own zombies first. Windows can't reach such leftovers (no Job Object). kill_process_tree()never raises. It runs while a timeout or interrupt propagates; a second Ctrl-C mid-way is held until every step (SIGKILL,proc.kill(), reap, group poll) has run, else an SIGTERM-ignoring descendant is orphaned and the unreapedPopenis aResourceWarning.proc.kill()covers a child not leading its group. It returns whether the tree was confirmed gone, which picks the timeoutWARNING:'s "terminated" vs "could not confirm". POSIX: SIGTERM, 3 s grace, SIGKILL, 2 s reap, pollkillpg(pgid, 0)up to 3 s -- about 8 s worst case, insidedocker stop's 10 s (the first version's 5 + 5 + 5 s did not fit).EPERMmeans "gone" on macOS only (zombies left; it can't be told from survivors owned by another user), a live unsignalable member on Linux. Each probe is preceded bywaitpid(-pgid, WNOHANG): as PID 1 or a subreaper, killed descendants are Pitloom's zombies and would keep the probe succeeding (tested on Linux CI only). Windows: full-pathtaskkill.exe /F /T(bandit B607),proc.kill(), wait -- 60 + 30 s worst case, untuned.- Result. Non-zero exit ->
BuildSubprocessErrorwith the log's last line; exit 0 needs exactly one*.whl. - Bounded log tail. Last 8 KiB only, UTF-8
errors="replace"; theWARNING:gets one cleaned line (~200 chars); theDEBUG:tail lines are prefixed, so a::line never reaches a runner as a workflow command.
- Thread +
Thread.join(timeout=). Can detect a timeout but cannot actually stop the thread -- the hung build keeps running regardless, defeating the point. - A custom
runner=passed toProjectBuilder. Doesn't coverDefaultIsolatedEnv.install()'s own subprocess calls (see "Why subprocess, not a thread" above) -- only half the problem. subprocess.run(cmd, timeout=...). Convenient, but its post-killcommunicate()call on Windows re-opens the pipe-deadlock hazard "The 11 traps" step 6 exists to avoid; also gives up the slice-and-poll wait loop step 8 needs for Ctrl-C responsiveness.- A
psutildependency for tree-walking/killing.os.killpg()(POSIX) andtaskkill /T(Windows) each already do this natively without adding a new third-party dependency for one call site. CREATE_NEW_PROCESS_GROUPon Windows. Would disable Ctrl-C delivery to the child process for no benefit, sincetaskkill /Tdoesn't need a separate process group to find the tree.- Signal-handling alternatives (a handler raising wherever the
signal lands, one installed unconditionally,
SystemExitas the primary exit, ...) are in allow-build-termination.md. - A
[tool.pitloom]config key for the timeout. Rejected for the same reason--allow-builditself has none -- see "Decisions" above. 0meaning "unlimited". Rejected -- see "Decisions" above.- An integer-only flag (seconds only, no unit suffixes). Considered
for simplicity, but a plain "how long, in seconds, should I wait" is
an awkward thing for a human (or an agent talking to one) to reason
about past a couple of minutes;
1h30mreads far better than5400in a warning message or a recipe a user copy-pastes. - The full Go
time.ParseDurationgrammar, includingms/us/nsand fractional values (1.5h). Rejected: sub-second precision is meaningless for a build that takes seconds to minutes at best, and a baremnext to an allowedmsis a one-character typo trap (90mvs90msdiffer by three orders of magnitude) not worth the surface area for a value this coarse-grained. - A
strduration in the library API. A Python caller already has the natural, unambiguous90 * 60at hand; parsing a string there would add failure modes (the same rejected-grammar edge cases) with no expressiveness gained over a plainint.
parse_build_timeout()'s grammar (bare integer = seconds, or h/m/s
units largest-first, each at most once, no decimals/whitespace) is a
strict subset of the below -- every string Pitloom accepts means the
same thing in each of these tools; nothing Pitloom accepts is
interpreted differently elsewhere.
| Form | Go time.ParseDuration / Prometheus |
GNU timeout |
pip (--timeout) |
Pitloom |
|---|---|---|---|---|
Bare integer (900) |
Not accepted (needs a unit) | Seconds | Seconds | Seconds |
900s |
Seconds | Seconds (s suffix) |
Not accepted | Seconds |
15m |
Minutes | Minutes (m suffix) |
Not accepted | Minutes |
1h30m |
1h30m | Not accepted (single unit only) | Not accepted | 1h30m |
1.5h |
Accepted (fractional) | Not accepted | Not accepted | Rejected |
500ms |
Accepted | Not accepted | Not accepted | Rejected |
1d |
Not accepted (no day unit) | Accepted (d suffix) |
Not accepted | Rejected |
Pitloom deliberately sits at the intersection: it never accepts a form
that Go/Prometheus would read as something numerically different, and
it never invents a unit (d) that Go/Prometheus don't have.
- Signals Pitloom cannot handle (SIGKILL, a host application's own handler, Windows forced termination, ...): see allow-build-termination.md.
- Windows descendants of an exited child.
taskkill /Twalks by parent PID; see traps 8 and 9. - Encoding. Python descendants get
PYTHONIOENCODING=utf-8, so the log is written in the encoding_read_log_tail()decodes (on a Windows cp1252 locale a redirected stdout would otherwise not be UTF-8). A non-Python tool's output is still in its own encoding; undecodable bytes become U+FFFD. Pitloom's ownWARNING:wording is ASCII; a project path or a build-log line in it may not be, andsys.stderr'sbackslashreplaceerror handler keeps that from raising on a cp1252 console. setsid()escape. A build backend that callssetsid()on itself detaches from the process groupos.killpg()targets, and survives the kill. No known real-world backend does this; documented as a residual risk, not exploitable by an accidental hang (only a backend deliberately trying to survive termination).