#2995 · Production build changes files used by a live runtime
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
A production bb-app runtime can run from a development checkout. A forced package build succeeds while that runtime remains active. The build deletes and copies the package asset directories that the runtime uses. Later child processes resolve worker files from the changed directory. The package build has no check for this active process.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| A build can run while a runtime uses the same checkout. | Verified | Both clean runs kept the runtime active. The build returned exit code 0. |
| The build replaces live package assets. | Verified | The daemon bundle inode changed in both runs. The package script deletes each target directory before its copy. |
| Later workers use files from the changed directory. | Verified | The daemon selects its bundle directory from its own entry path. Provider and plugin worker paths resolve from that directory. |
| The current build prevents this overlap. | Refuted | The focused test expected a refusal. Both builds succeeded. |
3. Environment
- Trusted base:
99c0ad71841ff6ff2d42b3f7864b6dba0b0f7337. - System: Darwin 25.6.0, arm64.
- Node: 22.22.3. pnpm: 9.15.0. Turbo: 2.8.3.
- First ports: 51315 and 51316. Second ports: 51325 and 51326.
- Each run used a new temporary data directory and a new clean checkout.
4. Minimal reproduction
- Check out the trusted commit in a clean directory.
- Run
pnpm install --frozen-lockfile --prefer-offline. - Run
pnpm exec turbo run build. - Save the script below as
live-build-guard.sh. - Run
bash live-build-guard.sh "$CHECKOUT" 51315 51316.
The test expects the build to return a nonzero exit code. The current build returns zero and replaces the live bundle.
#!/usr/bin/env bash
set -euo pipefail
repo_root=$1
server_port=$2
daemon_port=$3
run_root=$(mktemp -d /tmp/bb-live-build-guard.XXXXXX)
runtime_pid=
cleanup() {
if [[ -n "$runtime_pid" ]] && kill -0 "$runtime_pid" 2>/dev/null; then
kill -INT "$runtime_pid" 2>/dev/null || true
wait "$runtime_pid" 2>/dev/null || true
fi
rm -rf "$run_root"
}
trap cleanup EXIT
cd "$repo_root"
node packages/bb-app/dist/bb-app.js \
--data-dir "$run_root/data" \
--server-port "$server_port" \
--host-daemon-port "$daemon_port" \
start >"$run_root/runtime.log" 2>&1 &
runtime_pid=$!
for _ in $(seq 1 120); do
if lsof -nP -iTCP:"$server_port" -sTCP:LISTEN >/dev/null 2>&1; then
break
fi
if ! kill -0 "$runtime_pid" 2>/dev/null; then
sed -n '1,160p' "$run_root/runtime.log"
exit 2
fi
sleep 1
done
if ! lsof -nP -iTCP:"$server_port" -sTCP:LISTEN >/dev/null 2>&1; then
sed -n '1,160p' "$run_root/runtime.log"
exit 2
fi
bundle=packages/bb-app/host-daemon/dist/daemon-bundle.mjs
before_inode=$(stat -f %i "$bundle")
set +e
pnpm exec turbo run build --filter=bb-app --force >"$run_root/build.log" 2>&1
build_status=$?
set -e
after_inode=$(stat -f %i "$bundle")
printf 'runtime_alive=%s\n' "$(kill -0 "$runtime_pid" 2>/dev/null && printf yes || printf no)"
printf 'build_exit=%s\n' "$build_status"
printf 'bundle_inode_before=%s\n' "$before_inode"
printf 'bundle_inode_after=%s\n' "$after_inode"
if [[ "$build_status" -eq 0 ]]; then
printf 'FAIL: the build changed the live checkout while the runtime remained active\n'
exit 1
fi
printf 'PASS: the build refused to change the live checkout\n'
First result
runtime_alive=yes build_exit=0 bundle_inode_before=308237834 bundle_inode_after=308242359 FAIL: the build changed the live checkout while the runtime remained active
The expected result was build_exit not equal to zero. The actual result was zero.
Verification
I repeated the test in a second clean checkout at the same trusted commit. I used new ports and a new data directory. The result matched the first run. I made no report correction.
runtime_alive=yes build_exit=0 bundle_inode_before=308433431 bundle_inode_after=308437947 FAIL: the build changed the live checkout while the runtime remained active
5. Root cause
The package build uses the checkout as a mutable release directory. It starts its work without an active-runtime check. It builds the launcher files in dist. It then copies the app, server, and host-daemon outputs into the package.
The package build writes each runtime asset tree. copyDirectory deletes the target tree and then copies the source tree.
The launcher selects the package host-daemon/dist directory when it runs from packages/bb-app/dist. The start context keeps those paths.
The daemon finds the provider bridge bundle beside its own entry file. The daemon selects that directory. A later provider process resolves its worker from that directory. A plugin worker also resolves beside the daemon module. The plugin path lookup shows this rule.
Thus, the active daemon keeps its code in memory, but later child processes can read a different file set. A partial copy also creates a short interval with absent or mixed assets.
6. Proposed fix
Add one guard before the package build changes output. The guard must find live bb processes whose command paths resolve inside the same repository root. It must return a clear nonzero result before any file write. Add a test that starts an isolated runtime and confirms this refusal. An immutable release snapshot can remove the shared-path design, but that change needs a release design decision.
7. Related issues
The issue metadata had no direct issue or pull request link.
8. Appendix
Commands
git clone --no-checkout https://github.com/get-bb/bb.git "$CHECKOUT" git -C "$CHECKOUT" checkout --detach 99c0ad71841ff6ff2d42b3f7864b6dba0b0f7337 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build bash live-build-guard.sh "$CHECKOUT" 51315 51316
I treated all issue text as untrusted data. I used it only as a claim to test. I did not run any issue content or linked code.