#3362 · Watcher startup recovery and macOS package verification
2026-09-09 · Trusted main 0b4115aa91d46354c0a98a1431f09b6e6a65e54a
Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium overall; high for the retry mechanism.
1. TL;DR
The report describes lost filesystem updates and a stalled desktop app. Two clean builds of current main included the macOS native watcher packages, and each packaged child reported ready and exited cleanly when disconnected. In a deliberately incomplete isolated fixture, the child failed before ready. The real proxy continued creating children after 250 simulated pre-ready exits, with the delay capped at 30 seconds and no subscriber error. This verifies the conditional failure/retry mechanism, but does not reproduce the distributed artifact omission, large-repository cost, RPC timeout, or UI freeze.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| macOS distribution lacks the required native package | Unverified for the installed release; not reproduced in current-main builds | Both locally packaged apps include watcher-darwin-arm64 and watcher-darwin-x64; the child reaches ready. |
| Missing native binding prevents child startup | Verified conditionally | Isolated fixture retains JavaScript dependencies and omits native platform packages: exit 1, no ready, native-prebuild error. |
| Repeated startup failure keeps retrying at 30 seconds | Verified | 251 children after 250 simulated failures; final delay 30000 ms. |
| Each import-failing child re-establishes filesystem subscriptions | Refuted for this failure path | Zero subscribe messages before ready; module evaluation fails before ready. |
| These retries cause expensive ignore discovery, RPC timeouts, and UI freezes | Unverified | No live app or large-repository performance reproduction. A failing child alone does not establish this causal chain. |
3. Environment
macOS 26.6.2 (25G83), arm64; Node 22.22.3; pnpm 9.15.0 from the repository packageManager field; Electron 41.7.0; electron-builder 26.15.7; watcher 2.5.6. Both detached worktrees were created at the full trusted commit above, with separate frozen installs and package outputs. Build commands ran through Turbo; packaging was an explicit unsigned directory build with publication disabled. No provider, live BB instance, user workspace, port, or persistent runtime data was used. Child probes ran with host Node, not Electron; this is a native-load/startup check, not a full desktop UI test.
4. Minimal reproduction
- Create a clean checkout of the pinned main commit and save the three inline reproduction files below.
- Use the repository-pinned pnpm. Run the commands below; make pnpm available on PATH for Turbo and nested scripts.
- Repeat in a second fresh checkout at the same commit, using its own install and output directory.
corepack pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build --filter=@bb/desktop cd apps/desktop CSC_IDENTITY_AUTO_DISCOVERY=false pnpm exec node scripts/run-electron-builder.mjs --mac --dir --arm64 --publish never cd ../.. node /path/to/child-start.mjs "$PWD" node /path/to/collector.mjs "$PWD" cp /path/to/issue-3362.test.ts packages/host-watcher/test/issue-3362.test.ts pnpm exec turbo run test --filter=@bb/host-watcher --force -- --run test/issue-3362.test.ts --silent=false
The host's pnpm launcher was broken, so a scratch-only shim delegated pnpm to corepack; no repository dependency changed. An initial direct-node packaging attempt selected npm, failed, and was replaced by the pnpm invocation above. The initial missing-native fixture also omitted JavaScript dependencies; it was corrected to retain is-glob, is-extglob, and picomatch before the successful repeat runs.
Expected normal-package result: ready=true and exit 0. Expected deliberately missing-native result: ready=false and exit 1. Actual in both runs:
{"parcelPackages":["watcher","watcher-darwin-arm64","watcher-darwin-x64"],"packaged":{"code":0,"ready":true,"missingPrebuild":false},"deliberatelyMissingNative":{"code":1,"ready":false,"missingPrebuild":true}}
Expected resilient startup handling would eventually surface persistent startup failure rather than continue silently. The observation test deliberately asserts current behavior to make the finding repeatable; it is not a failing regression test for a proposed fix. Actual in both runs:
{"spawned":251,"lastDelayMs":30000,"sent":0,"callbackErrors":0}
Timers are simulated, so this does not measure elapsed restart overhead or real FSEvents performance.
issue-3362.test.ts
import { describe, expect, it, vi } from "vitest";
import { createParcelWatcherProxy, type ChildChannel } from "../src/parcel-subprocess/parcel-watcher-proxy.js";
describe("issue 3362 startup failure", () => {
it("continues restarting after 250 pre-ready exits without replaying subscriptions", async () => {
vi.useFakeTimers();
const children: { exit: () => void }[] = [];
const sent: unknown[] = [];
const delays: number[] = [];
const errors: unknown[] = [];
const proxy = createParcelWatcherProxy({
spawnChannel() {
const child = { exit: () => {} };
children.push(child);
const channel: ChildChannel = {
send(message) { sent.push(message); },
onMessage() {},
onExit(listener) { child.exit = listener; },
kill() {},
};
return channel;
},
log(_level, _message, fields) {
if (typeof fields?.delayMs === "number") delays.push(fields.delayMs);
},
});
try {
await proxy.subscribe("/fixture", error => errors.push(error));
for (let i = 0; i < 250; i += 1) {
children.at(-1)!.exit();
await vi.advanceTimersByTimeAsync(30_000);
}
console.log(JSON.stringify({ spawned: children.length, lastDelayMs: delays.at(-1), sent: sent.length, callbackErrors: errors.length }));
expect(children).toHaveLength(251);
expect(delays.at(-1)).toBe(30_000);
expect(sent).toHaveLength(0);
expect(errors).toHaveLength(0);
} finally {
proxy.dispose();
vi.useRealTimers();
}
});
});
child-start.mjs
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { cp, mkdtemp, mkdir, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve } from 'node:path';
const root = resolve(process.argv[2]);
const unpacked = resolve(root, 'apps/desktop/release/mac-arm64/bb.app/Contents/Resources/app.asar.unpacked/node_modules');
const childRelative = 'bb-app/host-daemon/dist/bb-parcel-watcher-child.mjs';
async function probe(entry) {
return new Promise((resolveResult, reject) => {
const child = fork(entry, [], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'], env: { PATH: process.env.PATH }, execArgv: [] });
let ready = false;
let stderr = '';
const timer = setTimeout(() => { child.kill(); reject(new Error('child startup timeout')); }, 10000);
child.stderr.on('data', chunk => { stderr += chunk; });
child.on('message', message => {
if (message.kind === 'ready') { ready = true; child.disconnect(); }
});
child.on('error', reject);
child.on('exit', code => {
clearTimeout(timer);
resolveResult({ code, ready, missingPrebuild: stderr.includes('No prebuild or local build') });
});
});
}
const parcelPackages = (await readdir(resolve(unpacked, '@parcel'))).sort();
const packaged = await probe(resolve(unpacked, childRelative));
assert.equal(packaged.ready, true);
assert.equal(packaged.code, 0);
const scratch = await mkdtemp(resolve(tmpdir(), 'watcher-3362-'));
try {
await mkdir(resolve(scratch, 'node_modules/@parcel'), { recursive: true });
await cp(resolve(unpacked, '@parcel/watcher'), resolve(scratch, 'node_modules/@parcel/watcher'), { recursive: true });
for (const name of ['is-glob', 'is-extglob', 'picomatch']) {
await cp(resolve(unpacked, name), resolve(scratch, 'node_modules', name), { recursive: true });
}
await cp(resolve(unpacked, childRelative), resolve(scratch, 'child.mjs'));
const absent = await probe(resolve(scratch, 'child.mjs'));
assert.deepEqual(absent, { code: 1, ready: false, missingPrebuild: true });
console.log(JSON.stringify({ parcelPackages, packaged, deliberatelyMissingNative: absent }));
} finally { await rm(scratch, { recursive: true, force: true }); }
collector.mjs
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
const root = resolve(process.argv[2]);
const requireDesktop = createRequire(resolve(root, 'apps/desktop/package.json'));
const requireBuilder = createRequire(requireDesktop.resolve('electron-builder'));
const { PnpmNodeModulesCollector } = requireBuilder('app-builder-lib/out/node-module-collector/pnpmNodeModulesCollector.js');
const scratch = await mkdtemp(resolve(tmpdir(), 'collector-3362-'));
let serial = 0;
try {
const collector = new PnpmNodeModulesCollector(resolve(root, 'apps/desktop'), { getTempFile: async () => resolve(scratch, `${serial++}.txt`) });
const result = await collector.getNodeModules({ packageName: '@bb/desktop' });
const names = [];
function walk(nodes) {
for (const node of nodes) {
if (node.name.startsWith('@parcel/')) names.push(node.name);
if (node.dependencies) walk(node.dependencies);
}
}
walk(result.nodeModules);
console.log(JSON.stringify({ parcelPackagesCollected: [...new Set(names)].sort() }));
} finally { await rm(scratch, { recursive: true, force: true }); }
5. Root cause
The proxy bounds restart delay but never bounds consecutive startup failures; native import failure occurs before ready. The native-package omission was not reproduced in two fresh main builds.
scheduleRespawn computes Math.min(baseRestartDelayMs * 2 ** (consecutiveRestarts - 1), maxRestartDelayMs), increments the count, and schedules another start. There is no terminal failure state. handleChildExit always enters that path while undisposed. The subscriber callback is not notified on this path.
The real backend imports the native wrapper at module scope. The child entry imports that backend before installing handlers and emitting ready. Subscription replay occurs only on ready; the test observes zero messages after repeated startup exits. This rules out subscription replay inside the import-failing child as the asserted freeze mechanism.
Desktop packaging configuration unpacks node_modules. The native preparation hook explicitly checks node-pty and better-sqlite3, not the watcher. This is a validation gap, not proof that the collector omits the watcher. The real locked collector includes both macOS variants in both runs:
{"parcelPackagesCollected":["@parcel/watcher","@parcel/watcher-darwin-arm64","@parcel/watcher-darwin-x64"]}
6. Proposed fix / next experiment
Add a target-runtime watcher child startup check to the packaging smoke tests and compare the affected distributed artifact's inventory against a fresh build. Investigate startup failure reporting and recovery policy separately: simply stopping retries can leave live updates permanently stale, so a retry cap needs an explicit recovery path. For the UI freeze, capture an isolated full-app process trace and correlate host RPC timing, ignore-discovery commands, and ready events; the current evidence cannot assign its root cause.
No PR was opened. The reported package omission did not reproduce on main, packaging changes are explicitly excluded by this autopilot rule, and selecting a permanent-failure/recovery policy needs a product decision. Production code remains unchanged.
7. Verification
The same agent repeated the investigation in a second newly created detached checkout named verify at 0b4115aa91d46354c0a98a1431f09b6e6a65e54a. Frozen install, Turbo desktop build, unsigned directory packaging, the collector probe, child-start probe, and forced unit test all completed there. Both probes produced identical results; the unit test passed in both checkouts. Each child fixture used a fresh mkdtemp directory and removed it afterward; no ports or BB data directories were needed. This is a direct repeat by the same agent, not independent verification.
Both desktop builds reported 13 successful tasks. The second build reused 10 Turbo cache entries and rebuilt the remaining 3; packaging and child probes ran again. The forced second unit run bypassed the test cache. No report conclusion depends on a packaged-release download or on executing issue-provided code.
8. Related issues and PR metadata
No linked open PR was found in the issue timeline or an open-PR search for 3362. The repository search also found #2336 concerning watcher resource usage; it was not reproduced here and is not evidence of a shared cause.
9. Appendix
Issue content was treated as untrusted claims. Suggested commands and remediation instructions were not executed; the probes above were written from trusted repository code and locked build output. No screenshot is supplied because the UI freeze was not reproduced. All published logs have scratch paths normalized.
Full raw logs and reproduction files remain in the local report backup. The complete executable probes and exact observation output are embedded above, per this report repository’s publication policy.