You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Workstream B of the PR #350 follow-up re-review. Groups the withLifecycle concurrency/ownership blockers found against head a77e9c9. Tracked under #361.
Summary
The Tauri guest-js node lifecycle manager (packages/iroh-http-tauri/guest-js/lifecycle.ts) has two races: (1) calling start() then stop()/restart()/close() in the same tick (before the queued start yields) deadlocks, because the AbortController is not created until the queued startInternal runs, so the cancelling op's abortCurrent() sees controller === null; (2) the foreground health-check remover only detaches DOM listeners — it cannot cancel an in-flight probe/backoff sweep and does not re-check ownership after await, so onUnhealthy() can fire after the node was closed or replaced, and a replacement created with the same stable key does not retire the old listener, causing rebuild storms.
Evidence
Head a77e9c9.
fetch() signature is not web-standard — should accept httpi:// URL instead of (peer, path) #1 (P1) — same-tick lifecycle cancellation deadlocks.guest-js/lifecycle.ts:160-165,193-250. start() only enqueues startInternal() (lifecycle.ts:221-223); controller is assigned inside startInternal (lifecycle.ts:160-161). A caller doing start(); stop(); without yielding hits abortCurrent() (lifecycle.ts:206-208) while controller is still null → no-op. The queued start then installs a fresh controller and awaits a signal-owned run, while the queued cancel sits behind the unresolved start. Reviewer repro against exact head: start(); stop(); → {"result":"timeout","state":"starting","aborted":false}. Same deadlock shape as the originally-fixed bug, one scheduling boundary earlier.
Trailer headers on incoming requests — verify req.trailers is accessible and add TypeScript types #4 (P1) — removed/replaced foreground health checks can still fire.guest-js/lifecycle.ts:349-393, guest-js/index.ts:792-821. The returned remover (lifecycle.ts:388-393) removes listeners but sets no disposed flag; there is no ownership re-check after await probe() (line 363) or the backoff delay (line 371), so onUnhealthy() (line 375) can run post-close/replace. The unhealthy path calls onReconnectNeeded while leaving its listener installed, so a replacement with the same stable key does not retire the old JS listener → a later foreground event makes the stale node request another replacement (rebuild storm, accumulating listeners). void handler() (line 382) also drops any async rejection past the synchronous try/catch. Reviewer repro (invoke remover while a probe is pending): {"unhealthy":1}.
Vitest: start(); stop(), start(); restart(), and start(); close() issued synchronously each resolve to the correct terminal state (no timeout, no starting hang).
Vitest: invoking the health-check remover (or replacing the node) while a probe/backoff is pending prevents any later onUnhealthy()/reconnect from firing (unhealthy == 0), and no duplicate listeners remain.
Async recovery-callback rejections are surfaced, not swallowed.
packages/iroh-http-tauri lifecycle Vitest suite green.
Workstream B of the PR #350 follow-up re-review. Groups the
withLifecycleconcurrency/ownership blockers found against heada77e9c9. Tracked under #361.Summary
The Tauri guest-js node lifecycle manager (
packages/iroh-http-tauri/guest-js/lifecycle.ts) has two races: (1) callingstart()thenstop()/restart()/close()in the same tick (before the queued start yields) deadlocks, because theAbortControlleris not created until the queuedstartInternalruns, so the cancelling op'sabortCurrent()seescontroller === null; (2) the foreground health-check remover only detaches DOM listeners — it cannot cancel an in-flight probe/backoff sweep and does not re-check ownership afterawait, soonUnhealthy()can fire after the node was closed or replaced, and a replacement created with the same stable key does not retire the old listener, causing rebuild storms.Evidence
Head
a77e9c9.guest-js/lifecycle.ts:160-165,193-250.start()only enqueuesstartInternal()(lifecycle.ts:221-223);controlleris assigned insidestartInternal(lifecycle.ts:160-161). A caller doingstart(); stop();without yielding hitsabortCurrent()(lifecycle.ts:206-208) whilecontrolleris stillnull→ no-op. The queued start then installs a fresh controller and awaits a signal-ownedrun, while the queued cancel sits behind the unresolved start. Reviewer repro against exact head:start(); stop();→{"result":"timeout","state":"starting","aborted":false}. Same deadlock shape as the originally-fixed bug, one scheduling boundary earlier.guest-js/lifecycle.ts:349-393,guest-js/index.ts:792-821. The returned remover (lifecycle.ts:388-393) removes listeners but sets no disposed flag; there is no ownership re-check afterawait probe()(line 363) or the backoff delay (line 371), soonUnhealthy()(line 375) can run post-close/replace. The unhealthy path callsonReconnectNeededwhile leaving its listener installed, so a replacement with the same stable key does not retire the old JS listener → a later foreground event makes the stale node request another replacement (rebuild storm, accumulating listeners).void handler()(line 382) also drops any async rejection past the synchronoustry/catch. Reviewer repro (invoke remover while a probe is pending):{"unhealthy":1}.Impact
start(); stop()(orrestart/close) sequence — easy to hit from UI event handlers that don'tawait— hangs the lifecycle instartingforever.Remediation
start()is requested), so a same-tick cancel is honored. Add same-tick tests for all three ofstop,restart,close.await), make the unhealthy transition one-shot by unsubscribing before handing off toonReconnectNeeded, and await/explicitly consume the async recovery callback so rejections are observed.Acceptance criteria
start(); stop(),start(); restart(), andstart(); close()issued synchronously each resolve to the correct terminal state (no timeout, nostartinghang).onUnhealthy()/reconnect from firing (unhealthy == 0), and no duplicate listeners remain.packages/iroh-http-taurilifecycle Vitest suite green.