Problem
fbuild is distributed via PyPI with [project.scripts] fbuild = "ci.bin_launcher:main". That makes the fbuild on PATH a pip-generated console-script .exe that imports ci.bin_launcher and os.execvs the real native binary bundled at ci/bin/fbuild[.exe].
On POSIX, execv replaces the process — semantically equivalent to cmd → rust binary. On Windows, os.execv is emulated as CreateProcess + parent exit (no true exec). The Python shim returns to cmd.exe before the spawned Rust binary finishes flushing its output, so the next shell prompt races ahead of fbuild's stdout. Repro:
C:\Users\niteris\dev\fbuild>fbuild port scan
C:\Users\niteris\dev\fbuild>COM25 303A:1001 USB Serial Device (COM25) ser=80:F1:B2:D1:DF:B1
└─ Espressif Systems / ESP32-S3 USB-CDC
...
Notice the next C:\Users\niteris\dev\fbuild> prompt prints before the scan output. Looks like output went to stderr; it didn't — it's the launcher exiting before the child's stdout drains.
Running the bundled binary directly (ci\bin\fbuild.exe port scan) preserves ordering perfectly. The problem is purely the Python shim.
Root cause
The Python shim exists at all only because [project.scripts] in PEP 621 is strictly for Python entry points. To ship a native binary through a wheel, the project used a Python launcher (ci/bin_launcher.py) that execvs into the staged binary — which doesn't work as advertised on Windows.
Fix
Drop the Python shim entirely. Ship the cargo-built binary directly via setuptools scripts= (legacy interface, well-supported, used by maturin/cargo-dist/scikit-build for exactly this). Files in scripts= land in <name>-<version>.data/scripts/ inside the wheel and pip copies them straight into the venv Scripts/ (Windows) or bin/ (POSIX) directory as-is — pip does NOT generate a wrapper for .exe files. Result: cmd → fbuild.exe → stdout, no Python in the chain.
Plan
pyproject.toml:
- Remove
[project.scripts] fbuild = "ci.bin_launcher:main"
- Remove
ci from [tool.setuptools] packages (no longer needs to be importable for the entry point; keep it as a directory only if other Python helpers in ci/ are still imported by anything — they aren't from end users)
setup.py:
- Pass
scripts=[str(STAGED_BINARY_PATH)] to setup(). The BuildWithCargo build_py cmdclass already stages the binary into ci/bin/ before packaging, so the path resolves at sdist/wheel build time.
- Verify ordering:
build_py must run before the build_scripts/install_scripts phases read the script list. Setuptools' default ordering handles this, but document it.
- Delete
ci/bin_launcher.py and its __pycache__.
- Update
pyproject.toml [tool.setuptools.package-data] — drop ci = ["bin/fbuild*"] since the binary is no longer part of a package, it's a script.
- Verify the wheel layout (
unzip -l dist/fbuild-*.whl): expect fbuild-2.2.31.data/scripts/fbuild.exe, NOT ci/bin/fbuild.exe.
Acceptance criteria
pip install --force-reinstall . produces a fbuild on PATH that is a real native exe (not a pip console-script stub).
- Windows:
file \$(where fbuild) reports PE32+ executable, not Zip archive, with extra data prepended.
- POSIX:
file \$(which fbuild) reports ELF/Mach-O, not a Python shebang.
cmd.exe /c 'echo === BEFORE === & fbuild port scan & echo === AFTER ===' shows === AFTER === after the scan output, not before.
- All existing fbuild functionality keeps working (smoke:
fbuild --version, fbuild port scan, fbuild build).
- Release workflow (
.github/workflows/release-auto.yml → ci/publish.py) still produces installable platform-tagged wheels.
Why this is better than the current subprocess.run workaround (#TBD)
#TBD landed a Windows-specific subprocess.run + wait fix in ci/bin_launcher.py that keeps the shim alive until the child exits. That fixes output ordering but still incurs Python startup on every fbuild invocation (~80–150 ms wasted) and keeps a non-trivial shim in the load path. Direct binary shipping removes both costs and the maintenance surface for the shim.
Problem
fbuildis distributed via PyPI with[project.scripts] fbuild = "ci.bin_launcher:main". That makes thefbuildon PATH a pip-generated console-script.exethat importsci.bin_launcherandos.execvs the real native binary bundled atci/bin/fbuild[.exe].On POSIX,
execvreplaces the process — semantically equivalent tocmd → rust binary. On Windows,os.execvis emulated asCreateProcess+ parent exit (no true exec). The Python shim returns tocmd.exebefore the spawned Rust binary finishes flushing its output, so the next shell prompt races ahead offbuild's stdout. Repro:Notice the next
C:\Users\niteris\dev\fbuild>prompt prints before the scan output. Looks like output went to stderr; it didn't — it's the launcher exiting before the child's stdout drains.Running the bundled binary directly (
ci\bin\fbuild.exe port scan) preserves ordering perfectly. The problem is purely the Python shim.Root cause
The Python shim exists at all only because
[project.scripts]in PEP 621 is strictly for Python entry points. To ship a native binary through a wheel, the project used a Python launcher (ci/bin_launcher.py) thatexecvs into the staged binary — which doesn't work as advertised on Windows.Fix
Drop the Python shim entirely. Ship the cargo-built binary directly via setuptools
scripts=(legacy interface, well-supported, used by maturin/cargo-dist/scikit-build for exactly this). Files inscripts=land in<name>-<version>.data/scripts/inside the wheel and pip copies them straight into the venvScripts/(Windows) orbin/(POSIX) directory as-is — pip does NOT generate a wrapper for.exefiles. Result:cmd → fbuild.exe → stdout, no Python in the chain.Plan
pyproject.toml:[project.scripts] fbuild = "ci.bin_launcher:main"cifrom[tool.setuptools] packages(no longer needs to be importable for the entry point; keep it as a directory only if other Python helpers inci/are still imported by anything — they aren't from end users)setup.py:scripts=[str(STAGED_BINARY_PATH)]tosetup(). TheBuildWithCargobuild_pycmdclass already stages the binary intoci/bin/before packaging, so the path resolves at sdist/wheel build time.build_pymust run before thebuild_scripts/install_scriptsphases read the script list. Setuptools' default ordering handles this, but document it.ci/bin_launcher.pyand its__pycache__.pyproject.toml[tool.setuptools.package-data]— dropci = ["bin/fbuild*"]since the binary is no longer part of a package, it's a script.unzip -l dist/fbuild-*.whl): expectfbuild-2.2.31.data/scripts/fbuild.exe, NOTci/bin/fbuild.exe.Acceptance criteria
pip install --force-reinstall .produces afbuildon PATH that is a real native exe (not a pip console-script stub).file \$(where fbuild)reportsPE32+ executable, notZip archive, with extra data prepended.file \$(which fbuild)reportsELF/Mach-O, not a Python shebang.cmd.exe /c 'echo === BEFORE === & fbuild port scan & echo === AFTER ==='shows=== AFTER ===after the scan output, not before.fbuild --version,fbuild port scan,fbuild build)..github/workflows/release-auto.yml→ci/publish.py) still produces installable platform-tagged wheels.Why this is better than the current
subprocess.runworkaround (#TBD)#TBD landed a Windows-specific
subprocess.run+ wait fix inci/bin_launcher.pythat keeps the shim alive until the child exits. That fixes output ordering but still incurs Python startup on everyfbuildinvocation (~80–150 ms wasted) and keeps a non-trivial shim in the load path. Direct binary shipping removes both costs and the maintenance surface for the shim.