Skip to content

refactor(remote-bridge): incremental channel add/remove (stop restarting every channel) - #1454

Merged
Astro-Han merged 11 commits into
devfrom
claude/i1414
Jun 21, 2026
Merged

refactor(remote-bridge): incremental channel add/remove (stop restarting every channel)#1454
Astro-Han merged 11 commits into
devfrom
claude/i1414

Conversation

@Astro-Han

@Astro-Han Astro-Han commented Jun 20, 2026

Copy link
Copy Markdown
Owner

Summary

Connecting or disconnecting one remote channel no longer restarts the shared PawWork event stream or the other channels — only the affected channel starts or stops. Per-channel lifecycle becomes a first-class operation across three layers, and the UI flap-suppression that #1404 added as an interim is removed now that unaffected channels genuinely stay up.

  • remote-bridge/supervisor.ts — a PlatformSupervisor registry replaces the one-shot Promise.all. Each platform runs under its own child AbortController linked to the run signal; add() / remove() / stopAll() start, stop, and await a single platform without touching the others. Each entry carries a generation token so a retired or replaced loop's late status can't clobber a newer entry under the same name. A thin supervisePlatforms() wrapper is kept for fixed-set callers.
  • remote-bridge/gateway.tsApp is now a platform registry holding the injected factory and a desiredPlatforms source-of-truth map. addPlatform(config) runs the same hasRemoteAudience gate as cold start (so an incrementally-added channel can't bypass it), builds via the factory, registers with the Engine, and supervises it; a same-name re-pair retires the old loop but keeps its pointers. removePlatform(name) retires the loop and prunes that platform's session pointers. run() seeds the supervisor synchronously after hydrate and freezes the cold-start onReady set to that snapshot. The message handler drops an inbound from a platform that is no longer the live instance.
  • remote-bridge/engine.tsunregisterPlatform(name) drops the platform from the reconstruct index and discards active deliveries targeting it.
  • remote-bridge/session-pointers.tsclearPlatform(name) prunes one platform's mappings by remote-key prefix, keeping the event cursor and other platforms. On a successful write this lets a disconnect→reconnect of the same platform start fresh. The cleanup is best-effort: clearPlatform prunes in memory and then writes atomically, so a write failure leaves the on-disk snapshot intact and a stale pointer can revive after an immediate restart — an accepted behavior pinned by a session-pointers test, with a self-healing tombstone/retry left as follow-up.
  • desktop-electron/remote-bridge.tsconfirmPairing / disconnect go incremental on a live bridge; a full startBridge() is kept only for cold start, last-channel teardown, and recovery after a fatal stream. The live bridge is one { app, ac, runPromise } handle cleared whenever run() settles, so a post-fatal confirmPairing rebuilds instead of adding onto a dead app. Both flap-suppressions (the startBridge pre-mark skip and the onStatus connected→connecting guard) are deleted.

Why

The whole-bridge restart on every connect/disconnect was the root cause of the UI flap that #1404 suppressed at the UI layer. Suppression only hid the blink; it could not fix the real cost — restarting the shared event stream and re-draining every channel whenever one is added or removed (a window in which messages can be missed). This is the root fix #1404's review deferred to Wave 2: don't restart the unaffected channels, and the suppression becomes unnecessary.

A second-AI design pass (first-principles review) surfaced three correctness points folded in here: the audience gate must run on incremental add (kept in the gateway, not bypassable); a removed/replaced platform's in-flight inbound must be dropped (gateway message-handler liveness guard) and its routing/active delivery dropped (unregisterPlatform); and a single-platform disconnect prunes that platform's session pointers so a same-platform reconnect normally starts fresh (best-effort: a failed pointer write can revive a stale pointer after a restart, pinned by tests).

Related Issue

Closes #1414. Part of #1188. Follow-up sibling: #1426 (WeChat session auto-relogin) is intentionally out of scope here.

Human Review Status

Pending

Review Focus

  • PlatformSupervisor lifecycle correctness (supervisor.ts): the entry is registered before the loop starts (so the first synchronous starting passes the token guard) and done is filled on the same tick (so remove/stopAll always await the real loop); the generation token drops a retired loop's late callbacks.
  • The live-handle clearing on run() settle (desktop remote-bridge.ts): this is what makes "is a bridge live?" reliable, so a fatal stream rebuilds rather than adds onto a dead app.
  • The audience gate staying inside App.addPlatform (gateway.ts): an incrementally-added channel goes through the same hasRemoteAudience check as cold start.
  • addPlatform vs removePlatform pointer semantics: re-pair (addPlatform replacing a live same-name channel) keeps session pointers; disconnect (removePlatform) prunes them.

Risk Notes

  • Deletion / credentials behavior: removePlatform now prunes a single platform's session pointers via clearPlatform (orphaned parent links are left as-is; no remaining remote key references them). The last-channel teardown (stop bridge → credentials.clear()rmSync(statePath)) is unchanged.
  • Platform/packaging surface: none touched (no packaging, updater, signing, paths, shell, or permissions changes) — the change is OS-agnostic Electron main-process async logic, identical on macOS and Windows. The macOS/Windows checklist item is left unticked for that reason.
  • No visible UI or copy change: the channel rows and status pills render exactly as before; only the underlying restart behavior changed. The UI/copy checklist item is left unticked for that reason.
  • Manual real-app regression deferred (needs a live WeChat scan): bun run dev:desktop walk — with Telegram connected, connect/disconnect WeChat and confirm Telegram keeps serving (its poll is never aborted) — is not yet run, as it requires a live WeChat pairing, same constraint as feat(remote): WeChat iLink adapter with one-step QR pairing (#1188) #1404. The behavior is covered by unit tests asserting the shared stream is never rebuilt and the survivor is never stopped.

How To Verify

remote-bridge typecheck (tsgo --noEmit): exit 0
remote-bridge unit (bun test ./src): 174 pass, 0 fail (12 files)
  - supervisor: add on running supervisor; remove stops only target; retired loop's late ready dropped; no abort-listener accumulation
  - engine: unregisterPlatform drops active delivery + reconstruct routing
  - gateway: addPlatform keeps shared stream up (one /global/event connect); removePlatform stops only target; wildcard-audience add refused; stale-instance inbound dropped
  - session-pointers: clearPlatform prunes one prefix, keeps cursor + other platforms (memory + on-disk)
desktop-electron typecheck (tsgo -b): exit 0
desktop-electron unit (bun test ./src): 375 pass, 0 fail (55 files)
  - two channels: connect adds incrementally (build=1, events=[add:wechat]); disconnect removes only one (build=1)
  - connecting a second channel leaves the first's stream + status untouched (no flap)
  - fatal stream degrades all + clears the handle, so the next connect rebuilds (build=2)
eslint (changed in-scope file desktop-electron/src/main/remote-bridge.ts): 0 errors

Screenshots or Recordings

No visible UI change (main-process / gateway logic only).

Checklist

  • Type label — this PR carries exactly one of bug, enhancement, task, documentation. Type labels are author-added; the labeler bot does NOT assign them. Add the label in the GitHub UI, then tick this.
  • Routing labels — this PR carries at least one of app, ui, platform, harness, ci. The labeler bot assigns these on PR open based on changed paths. Confirm the bot's choice (or override if wrong), then tick this.
  • Priority label — this PR carries exactly one of P0, P1, P2, P3. The priority-triage bot suggests one on PR open. Confirm or override, then tick this.
  • Human Review Status above is set to Pending, Approved by @<reviewer>, or Not required: <reason> (default is Pending; "not required" is restricted to bot-authored low-risk PRs).
  • I linked the related issue, or stated in Summary why there is no issue.
  • I described the review focus and any meaningful risks.
  • I replaced the example block in How To Verify with the real verification steps and the key result for each.
  • I did not introduce unrelated refactors, dependencies, generated files, or file changes beyond the stated scope.
  • (conditional) I manually checked visible UI or copy changes when needed, with screenshots or recordings. Leave unticked only if no visible UI or copy changed.
  • (conditional) I considered macOS and Windows impact for platform, packaging, updater, signing, paths, shell, or permissions changes. Leave unticked only if no platform/packaging surface was touched.
  • (conditional) I called out docs, release notes, dependencies, permissions, credentials, deletion behavior, generated content, or local file changes when relevant. Leave unticked only if none of those surfaces was touched.
  • I reviewed the final diff for unrelated changes and suspicious dependency changes.
  • I am targeting dev, and my PR title and commit messages use Conventional Commits in English.

https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT

Summary by CodeRabbit

Release Notes

  • New Features
    • Incremental platform connect/disconnect while the bridge stays online, including prepare-first replacements.
    • Per-platform supervision with lifecycle phases (starting/serving/degraded) and more detailed serving status.
    • Runtime support for unregistering/removing a platform and keeping the rest online.
  • Bug Fixes
    • Dropped stale inbound messages from removed platforms and prevented routing to disconnected targets.
    • Improved teardown/shutdown safety, including retry behavior after transient disconnect failures.
    • Isolated credential save failures so incremental joins/adds/removes don’t overwrite persisted credentials.

…ing every channel)

Connecting or disconnecting one remote channel no longer tears down the shared
PawWork event stream and every other channel. The whole-bridge restart was the
root cause of the UI flap that #1404 papered over with suppression; this makes
per-channel lifecycle a first-class operation and removes the suppression.

- supervisor.ts: PlatformSupervisor registry replaces the one-shot Promise.all.
  Each platform runs under its own child AbortController linked to the run signal;
  add()/remove()/stopAll() start, stop, and await one platform without touching
  the others. Each entry carries a generation token so a retired/replaced loop's
  late status can't clobber a newer entry under the same name. The entry is
  registered before the loop starts so its first synchronous "starting" passes the
  token guard. A thin supervisePlatforms() wrapper is kept for fixed-set callers.
- gateway.ts: App is now a platform registry. It holds the injected factory and a
  desiredPlatforms map (source of truth). addPlatform(config) runs the same
  hasRemoteAudience gate as cold start, builds via the factory, registers with the
  Engine, and supervises it — so an incrementally-added channel can't bypass the
  audience check; a same-name re-pair retires the old loop but keeps its pointers.
  removePlatform(name) retires the loop and prunes that platform's session
  pointers. run() seeds the supervisor synchronously after hydrate and freezes the
  cold-start onReady set to that snapshot. The message handler drops an inbound
  from a platform that is no longer the live instance.
- engine.ts: unregisterPlatform(name) drops the platform from the reconstruct
  index and discards active deliveries targeting it.
- session-pointers.ts: clearPlatform(name) prunes one platform's mappings by
  remote-key prefix, keeping the event cursor and other platforms — so a
  disconnect→reconnect of the same platform can't resurrect a stale session.
- desktop remote-bridge.ts: confirm/disconnect go incremental on a live bridge;
  full startBridge() is kept only for cold start, last-channel teardown, and
  recovery after a fatal stream. The live bridge is one {app, ac, runPromise}
  handle cleared whenever run() settles, so a post-fatal confirm rebuilds instead
  of adding onto a dead app. Both flap-suppressions (the startBridge pre-mark skip
  and the onStatus connected->connecting guard) are deleted.

Tests assert "nothing restarted" via call counts, not UI suppression: the shared
stream is stood up once across a single-channel add/remove, only the removed
platform is stopped, and a fatal stream rebuilds. supervisor 174 / desktop 375
unit tests pass; both typecheck clean; eslint clean.

Closes #1414.

Claude-Session: https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Implements Wave 2 incremental per-channel add/remove: PlatformSupervisor replaces the fixed Promise.all supervision with a live per-platform AbortController registry and generation-token stale-callback suppression. Engine.unregisterPlatform and SessionPointers.clearPlatform clean up routing and session state on disconnect. App gains addPlatform/removePlatform backed by a desiredPlatforms map with prepare-first ordering and instance-identity gating. RemoteBridgeRuntime uses a current bridge handle to call addPlatform/removePlatform on a live bridge instead of rebuilding, reserving full rebuilds for cold start and fatal-stream recovery.

Changes

Incremental per-channel add/remove on live bridge

Layer / File(s) Summary
PlatformSupervisor: per-platform AbortController registry
packages/remote-bridge/src/supervisor.ts
PlatformSupervisor class with add/remove/stopAll/whenIdle backed by per-platform child AbortController and monotonic generation tokens for stale-callback suppression. supervisePlatforms() delegates to PlatformSupervisor instead of Promise.all over fixed array.
Supervisor test harness and lifecycle edge-case coverage
packages/remote-bridge/src/supervisor.test.ts
Test harness extended with ManualPlatform (manual readiness triggering) and StuckStopPlatform (wedged stop). Abort-listener leak test refactored to patch AbortSignal.prototype for cross-signal counting. New PlatformSupervisor lifecycle tests verify add on running supervisor, timeout-bounded remove, and generation-token suppression of late readiness from replaced entries.
Engine.unregisterPlatform and SessionPointers.clearPlatform
packages/remote-bridge/src/engine.ts, packages/remote-bridge/src/session-pointers.ts, packages/remote-bridge/src/session-pointers.test.ts
Engine.unregisterPlatform removes platform from routing and clears active delivery targets. SessionPointers.clearPlatform prunes platform-prefixed session mappings and persists. Tests verify targeted removal, sibling state preservation, file persistence, and atomic-write failure recovery.
App state refactoring: live desiredPlatforms and incremental createApp
packages/remote-bridge/src/gateway.ts
Refactors App constructor to accept pointers and factory instead of static platforms array. Replaces platforms: Platform[] with live desiredPlatforms: Map<string, Platform>. createApp stops eagerly building all platforms; now increments via await app.addPlatform(...). run() cold-start readiness gates on snapshot of initial desiredPlatforms seeded into PlatformSupervisor. Adds stopUnstartedPlatforms() helper.
App.addPlatform/removePlatform and stale-instance gating
packages/remote-bridge/src/gateway.ts
addPlatform validates audience, builds via factory, runs beforeCommit after success, swaps same-name instances only after preparation, registers with engine, stores in desiredPlatforms, and adds to supervisor when running. removePlatform retires and clears session pointers. messageHandler drops stale inbounds via instance identity checks.
Gateway incremental add/remove and re-pair behavior tests
packages/remote-bridge/src/gateway.test.ts
Extends test harness with SessionPointers.memory, FakePlatform stop counts and configurable start errors. Tests verify stale inbound drops, addPlatform while running starts only new platform without stream restart, removePlatform stops only that platform leaving others active, re-pair prepare-first ordering on factory/audience/start failures, teardown race BridgeClosedError handling, and wildcard audience rejection.
Desktop runtime BridgeApp contract and current-handle state
packages/desktop-electron/src/main/remote-bridge.ts
Exports BridgeApp with addPlatform(config, beforeCommit?) and removePlatform(name). Replaces per-field bookkeeping (ac, runPromise) with single nullable current handle {app, ac, runPromise} guarding async status/fatal handling and teardown coordination.
confirmPairing/disconnect: prepare-first incremental orchestration
packages/desktop-electron/src/main/remote-bridge.ts
confirmPairing on live bridge swaps in-memory accounts, calls current.app.addPlatform(...) with beforeCommit persisting credentials, rolls back on failure. Cold start commits credentials and performs full startBridge. disconnect handles last-channel removal (stops bridge, clears credentials) vs channels-remain (saves trimmed accounts, calls removePlatform or rebuilds).
startBridge/stop/stopBridge: full rebuild wiring and stop sequencing
packages/desktop-electron/src/main/remote-bridge.ts
stop() aborts live bridge immediately via current?.ac.abort(). startBridge() always marks all accounts connecting (removes prior "skip" logic). Gateway config passes platforms via buildFactory() helper. current handle published before app.run(). Status/fatal handling guarded by current identity. stopBridge() nulls current, aborts, and races runPromise against timeout.
Desktop runtime test harness: appWith/servingApp/bridgeHarness
packages/desktop-electron/src/main/remote-bridge.test.ts
Introduces appWith(run, over) lifting custom run into BridgeApp with throwing defaults, servingApp(names) emitting "serving" per platform, bridgeHarness() tracking rebuild count and ordered add:/remove: events. Extends memoryStore with failSave flag to simulate credential store failures.
Runtime incremental lifecycle and failure-mode tests
packages/desktop-electron/src/main/remote-bridge.test.ts
Updates multi-channel/teardown/concurrency tests to use new harnesses. Verifies double-confirm one build, fatal run degradation, status transitions, last-channel stop-before-delete ordering, incremental add no-rebuild, multi-channel events, fatal rebuild recovery, credential save failure isolation, disconnect retryability, and synchronous stop abort landing.

Sequence Diagram(s)

sequenceDiagram
  participant UI
  participant Runtime as RemoteBridgeRuntime
  participant BridgeApp
  participant PlatformSupervisor
  participant Engine

  rect rgba(100, 149, 237, 0.5)
    note over Runtime,Engine: Cold start — full rebuild
    UI->>Runtime: confirmPairing(first channel)
    Runtime->>Runtime: startBridge → mark all connecting
    Runtime->>BridgeApp: app.run()
    BridgeApp->>PlatformSupervisor: add(platform1)
    BridgeApp->>Engine: registerPlatform(platform1)
  end

  rect rgba(144, 238, 144, 0.5)
    note over Runtime,Engine: Incremental add on live bridge
    UI->>Runtime: confirmPairing(second channel)
    Runtime->>BridgeApp: addPlatform(config, beforeCommit)
    BridgeApp->>BridgeApp: beforeCommit() saves credentials
    BridgeApp->>Engine: registerPlatform(platform2)
    BridgeApp->>PlatformSupervisor: add(platform2) — platform1 unaffected
  end

  rect rgba(255, 165, 0, 0.5)
    note over Runtime,Engine: Incremental remove on live bridge
    UI->>Runtime: disconnect(platform2)
    Runtime->>BridgeApp: removePlatform(platform2)
    BridgeApp->>PlatformSupervisor: remove(platform2)
    BridgeApp->>Engine: unregisterPlatform(platform2)
    Note over BridgeApp: platform1 keeps serving, stream stays up
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • Astro-Han/pawwork#1390: Established the multi-channel RemoteBridgeRuntime, per-platform PlatformStatus, and gateway/supervisor lifecycle that this PR refactors with incremental add/remove and prepare-first connect/disconnect semantics.
  • Astro-Han/pawwork#1404: Added the WeChat channel and the flap-suppression workaround in remote-bridge.ts that this PR's incremental add/remove directly supersedes, eliminating the need for interim suppression.
  • Astro-Han/pawwork#1339: Introduced the Telegram RemoteBridgeRuntime pairing/connect/disconnect flow that this PR refactors to support incremental live BridgeApp addPlatform/removePlatform behavior.

Suggested labels

enhancement, desktop

🐇 No more pulling every thread to add just one,
Each channel now starts and stops on its own!
addPlatform, remove — the hop is precise,
The shared stream stays up, oh isn't that nice?
One rabbit removed; the others still run. 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title 'refactor(remote-bridge): incremental channel add/remove (stop restarting every channel)' clearly and specifically summarizes the main architectural change: enabling per-channel lifecycle operations without restarting the entire bridge.
Linked Issues check ✅ Passed The PR fulfills the objectives from linked issues #1404 and #1414: it implements per-platform AbortController in supervisor.ts, adds addPlatform/removePlatform methods to App, updates RemoteBridgeRuntime for incremental operations, and removes flap-suppression mechanisms as planned.
Out of Scope Changes check ✅ Passed All code changes are directly scoped to the stated objectives: supervisor lifecycle, gateway platform registry, engine unregisterPlatform, session-pointers clearPlatform, and desktop remote-bridge incremental pairing/disconnect. No unrelated refactors, dependencies, or generated files introduced.
Description check ✅ Passed The pull request description comprehensively covers all required sections: Summary explains the incremental channel add/remove feature, Why provides context about fixing the root cause of UI flap, Related Issue links to #1414 and #1404, Human Review Status is set to Pending, Review Focus details key correctness points, Risk Notes addresses deletion/credentials behavior and platform impact, How To Verify provides concrete test results with exit codes and test counts, and the Checklist is substantially complete with most items ticked and conditional items properly explained.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/i1414

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Astro-Han Astro-Han added the task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work label Jun 20, 2026
@github-actions github-actions Bot added platform Electron shell, OS integration, packaging, updater, signing, paths, and permissions harness Model harness, prompts, tool descriptions, and session mechanics labels Jun 20, 2026
@Astro-Han Astro-Han added the P2 Medium priority label Jun 20, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested priority: P2 (includes user-path files (packages/desktop-electron/src/main/remote-bridge.test.ts, packages/desktop-electron/src/main/remote-bridge.ts)).

P1/P0 are reserved for maintainer confirmation. Please relabel manually if this is a release blocker, security issue, data-loss risk, or updater/runtime failure.

Two correctness gaps surfaced by the PR #1454 review:

- addPlatform built the replacement before retiring a live same-name
  instance, so a factory failure left the old loop serving while the
  caller had already switched the saved account/UI to the new identity.
  Retire first, then build — matching the method's own doc.

- PlatformSupervisor.remove awaited a platform's stop()/wind-down with no
  bound, so a wedged stop() could hang a disconnect or re-pair and wedge
  the desktop's serial lifecycle queue (the channel never leaving the
  screen). Bound the wait with removeTimeoutMs; the entry is already
  dropped and the loop aborted before the wait, so timing out just
  detaches the stop().

Claude-Session: https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT
Review follow-ups on PR #1454:

- addPlatform ran the audience gate before retiring a live same-name
  instance, so a same-name re-pair with an invalid audience threw at the
  gate and left the old loop serving under the already-committed new
  identity. Retire first — before both the gate and the factory build —
  so any failed re-pair drops the old instance. A new name that fails the
  gate still leaves existing channels untouched.

- Tighten the bounded-remove test to assert < 250ms (was < 2000ms) so it
  proves remove() honors removeTimeoutMs rather than passing under a
  hypothetical hardcoded second-scale wait.

Claude-Session: https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT
PR #1454 review. Supersedes the retire-first approach from the two earlier
review rounds, which destroyed a working connection on a failed re-pair:

- A live re-pair now builds and connects the new channel BEFORE anything
  else; only on success does it retire the old loop and persist the new
  credential. gateway addPlatform reverts to prepare-first (build, then
  retire-on-success). desktop confirmPairing swaps the in-memory account,
  awaits addPlatform, saves the credential only on success, and on failure
  rolls the swap back and rethrows — leaving the working channel connected
  and its stored credential untouched. The earlier "old instance live under
  the new label" concern is resolved at the root by not committing the new
  account before the new channel is up, rather than by tearing the old down.

- Test harness: appWith's addPlatform/removePlatform now throw by default,
  so a test that unexpectedly drives an incremental path fails loudly
  instead of passing through a silent no-op (incremental tests override).

Claude-Session: https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT
A live re-pair / second-channel add swapped the gateway's live platform set
inside app.addPlatform and only then saved the credential, so a save failure
(locked keyring, unwritable file) left a live new channel backed by a stale
stored credential while the in-memory account list rolled back — a half-commit.

Add an optional beforeCommit hook to App.addPlatform that runs after the build
but before retire/register/supervise; the desktop saves the credential there.
A throwing hook aborts the swap with the old channel still serving and nothing
persisted. Cover both the re-pair and second-channel save-failure paths.

Claude-Session: https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT
…onnect

disconnect() removed the platform from this.accounts and only then saved the
trimmed list and called removePlatform, with no try/catch — so a save failure
(locked keyring, unwritable file) left the channel gone from memory while its
loop kept running live and its status froze. Apply the same prepare-first order
as add/re-pair: compute nextAccounts, persist first, and only on success swap
this.accounts and remove the live channel; a save failure leaves the channel
connected and the disconnect retryable. The last-channel teardown is unchanged
(it revokes via clear(), which needs no encryption).

Claude-Session: https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT
…ess replace)

A re-pair retires the old loop and supervises the new platform once it is built
and persisted, not once it is serving. Add a gateway test that re-pairs a name
with a replacement whose start() rejects, asserting the old channel is retired,
the new platform is the live one, and it surfaces degraded (the supervisor
retries it) rather than the old channel being kept. FakePlatform gains a
startErr option to model a connect-then-fail platform.

Claude-Session: https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT
@Astro-Han

Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…e tears down

A fatal shared-stream error during an in-flight confirmPairing/disconnect could
land in the window where App.run() has cleared the supervisor but the desktop
runtime's handle is not yet cleared. addPlatform/removePlatform then resumed and
silently no-op'd the live supervise (supervisor?.add on a null supervisor),
returning success — so the runtime reported a paired/disconnected channel that
never started, or whose survivors were never rebuilt.

Give the App an explicit tearingDown flag, set in the same synchronous step as
supervisor=null, and have addPlatform/removePlatform throw BridgeClosedError at
their commit point when it is set (a null supervisor during startup stays valid:
the initial snapshot adopts the platform). The runtime now caches the whole
handle and, on BridgeClosedError after the credential is committed, rebuilds the
bridge from the persisted accounts; a pre-commit failure still rolls back memory.

Tests: a gateway race where the build finishes after teardown now throws instead
of silently succeeding; desktop re-pair and disconnect interrupted by a fatal
stream recover by rebuilding from the persisted accounts.

Claude-Session: https://claude.ai/code/session_01WVUVErxQ1AEf2mYT2bJ5hT
…hannel ops

Two P2 races surfaced by review on the incremental add/remove path:

1. A user quit lands stop()'s synchronous handle abort while a re-pair or
   disconnect is parked in addPlatform/removePlatform. The abort makes the op
   throw BridgeClosedError, and both desktop catch blocks rebuilt the bridge
   unconditionally — standing up a fresh event stream during shutdown only to
   tear it down again. Skip the rebuild when the handle was aborted (the
   credential / trimmed accounts are already persisted; the next launch builds
   from them); a fatal stream, which does not abort the handle, still rebuilds.

2. removePlatform threw a pointer-store write failure past its live retire.
   The retire is the disconnect's commit (the channel has stopped serving), so
   forgetting its session pointers is best-effort cleanup: log a failed write
   instead of throwing, which otherwise surfaced as a failed disconnect and
   stranded the UI showing an already-stopped channel. A stale on-disk pointer
   self-heals on the next pointer save.

Tests cover each seam and were verified to fail when the guard is removed.

Claude-Session: https://claude.ai/code/session_01Y3Z6Hbny6bzGoZJg8Xrjjr
@Astro-Han

Copy link
Copy Markdown
Owner Author

Addressed both P2 items in 8ac29894e3.

P2 (stop during in-flight add/remove rebuilds the bridge mid-shutdown). Confirmed: stop() aborts the live handle on its synchronous prefix, so an in-flight addPlatform/removePlatform throws BridgeClosedError after the commit, and both desktop catch blocks then rebuilt unconditionally — standing up a fresh stream during shutdown only to tear it down again. Fix: in each catch, skip the rebuild when handle.ac.signal.aborted (a user-initiated stop), since the credential / trimmed accounts are already persisted and the queued stop is taking over; the next launch builds from the persisted accounts. A fatal stream does not abort the handle, so it still rebuilds to recover. Two runtime tests park the op at a gate, issue stop(), then release — asserting build === 1 (no rebuild). Both fail (build === 2) when the guard is removed.

P2 (non-last disconnect leaves a half-state when pointer cleanup fails). Confirmed: removePlatform did await retirePlatform then await clearPlatform, and a clearPlatform write failure threw past the retire — the desktop rethrew it and never reached statusMap.delete, so the UI kept showing a channel whose loop was already stopped and credential already deleted. Fix: the live retire is the disconnect's commit, so forgetting session pointers is best-effort — a failed pointer write is now logged and swallowed, not thrown. The desktop then reaches statusMap.delete and the UI is consistent; a stale on-disk pointer self-heals on the next pointer save. Gateway test asserts removePlatform resolves (and warns) when clearPlatform rejects; it fails when the swallow is removed.

Note on a residual: a fatal stream that races a stop within the in-flight window can still rebuild once during shutdown (the fatal path nulls current so stop()'s abort no-ops, leaving handle.ac un-aborted). It produces the same benign one-time flap and is much rarer than the deterministic stop-during-add case; covering it would need a dedicated stopping flag, which seemed like over-engineering for an identical outcome. Happy to add it if you'd prefer the seam fully closed.

…comments

Review follow-up (P3 + verification):

- Add a file-backed SessionPointers test for the write-failure restart chain:
  a clearPlatform whose atomic write fails leaves the in-memory map pruned but
  the on-disk snapshot intact, so a restart (and a reconnect of the same
  platform, since addPlatform keeps pointers) revives the stale mapping. This
  pins the accepted best-effort behavior as deliberate rather than a latent bug.
  The harm is bounded (a reconnected channel's first message may hit an old
  session; the engine surfaces an error and the user resends) and the trigger is
  a rare IO-failure conjunction, so a self-healing tombstone is left as
  follow-up, not blocking the incremental add/remove path. The forced-failure
  step skips under root, where a read-only dir cannot block writes.

- Compress the commit/rebuild invariant comments in addPlatform, removePlatform,
  confirmPairing, and disconnect to one-line statements now that tests cover the
  races; correct removePlatform's comment, which overclaimed the stale pointer
  was "never resurfaced". No logic change.

Claude-Session: https://claude.ai/code/session_01Y3Z6Hbny6bzGoZJg8Xrjjr
@Astro-Han

Copy link
Copy Markdown
Owner Author

Addressed both items in 042a7d5faf.

待验证 — does a failed pointer write revive a stale pointer on restart? Verified: yes. Added a file-backed SessionPointers test for the write-failure restart chain. clearPlatform prunes the in-memory map and then writes atomically (unique temp + rename), so a write failure leaves memory pruned but the on-disk snapshot intact; a restart reloads it and — since addPlatform keeps pointers on reconnect — the stale mapping resurfaces. The test forces the failure with a read-only directory (skipped under root, where that can't block writes) and asserts the revival.

Decision: accept best-effort for this PR. The harm is bounded (a reconnected channel's first message may route to an old session; if it's gone the engine surfaces an error and the user resends, starting fresh — no data loss, no security impact) and the trigger is a rare conjunction (clear write fails ∧ no later save heals the file ∧ immediate exit ∧ later reconnect of the same platform). The clean durable fix can't be "a fresh connect clears pointers" — cold start is also a non-re-pair addPlatform, so that would wipe every conversation on every launch; the only correct seam is making the disconnect's forget durable (a tombstone / retry), which is more persistent state than this IO-failure tail warrants. The test pins the behavior as deliberate; happy to open a follow-up issue for a tombstone if you'd prefer belt-and-suspenders.

P3 — trimmed the invariant comments. Compressed the multi-paragraph commit/rebuild explanations in addPlatform, removePlatform, confirmPairing, and disconnect to one-line invariants now that tests cover the races, and corrected removePlatform's comment which overclaimed the stale pointer was "never resurfaced". No logic change.

remote-bridge 181 tests / desktop-electron 586 / both typecheck / eslint in-scope all green.

… root

The stale-pointer revival test forces a clearPlatform write failure with a POSIX
chmod 0o500 on the directory. That cannot make a directory unwritable on Windows
(chmod has no effect there) nor under root (which bypasses permission bits), so on
those platforms clearPlatform would not reject and the assertion would fail or pass
implicitly. Move the guard into the test declaration as
test.skipIf(win32 || getuid()===0), matching the existing owner-only-permissions
test, so both cases render as an explicit skip instead of an in-body early return.

Claude-Session: https://claude.ai/code/session_01Y3Z6Hbny6bzGoZJg8Xrjjr
@Astro-Han

Copy link
Copy Markdown
Owner Author

Fixed in 66fd65761c.

P1 confirmed: the revival test forced the write failure with POSIX chmod 0o500, which can't make a directory unwritable on Windows (chmod is a no-op there) or under root (bypasses permission bits), so clearPlatform wouldn't reject and the assertion would fail / pass implicitly. Moved the guard into the test declaration — test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(...), matching the existing owner-only-permissions test in the same file — and removed the in-body root early-return. Verified: on POSIX non-root the test runs and passes; forcing the condition true renders it as an explicit 1 skip, not a silent pass. (For the record, unit-remote-bridge itself runs ubuntu-only, so this is portability/clarity hardening rather than a live CI break, but the explicit skip is the right form.)

remote-bridge 181 tests (1 skipped on Windows/root) / both typecheck / prettier clean.

@Astro-Han
Astro-Han merged commit b944bc8 into dev Jun 21, 2026
44 checks passed
@Astro-Han
Astro-Han deleted the claude/i1414 branch June 21, 2026 06:40
Astro-Han added a commit that referenced this pull request Jun 21, 2026
Release bump for the 2026.6.11 production hotfix build.\n\nThis updates the desktop package version and matching lockfile entry from 2026.6.10 to 2026.6.11 so the release workflow can publish a new build containing #1461, which fixes the packaged Settings IPC regression reported in #1460. The release also includes #1454, which landed after v2026.6.10.\n\nVerification before merge:\n- Local: bun install --frozen-lockfile, bun install --lockfile-only, package version readback, git diff --check, and packages/desktop-electron typecheck.\n- PR #1462: CodeQL, dependency-review, dev-dep-audit, desktop-smoke, e2e-artifacts, ci, windows-advisory, pr-triage, and title lint all passed.\n- Pre-bump dev validation for #1461 merge commit c509faf: ci, desktop-smoke, CodeQL, and windows-advisory passed; focused WebSearchAuth AppRuntime status smoke passed locally.\n\nRelated: #1460, #1461, #1454.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

harness Model harness, prompts, tool descriptions, and session mechanics P2 Medium priority platform Electron shell, OS integration, packaging, updater, signing, paths, and permissions task Narrow execution, audit, spike, migration, tracking, or upstream follow-up work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Task] Wave 2: incremental channel add/remove (stop restarting every channel on connect/disconnect)

1 participant