Skip to content

fix(channels): rebind inbound route handler on hot-reinstall (#395)#407

Open
pkalusek wants to merge 1 commit into
byte5ai:mainfrom
pkalusek:fix/395
Open

fix(channels): rebind inbound route handler on hot-reinstall (#395)#407
pkalusek wants to merge 1 commit into
byte5ai:mainfrom
pkalusek:fix/395

Conversation

@pkalusek

@pkalusek pkalusek commented Jul 2, 2026

Copy link
Copy Markdown

What

Channel plugin hot-reinstalls now rebind the inbound HTTP handler to the freshly-loaded module instead of silently serving stale code.

ExpressRouteRegistry.register() mounted a fresh Express route on every call. A hot-reinstall re-runs the plugin's activate()core.registerRoute(...), which pushed a second app.<method>(path) for the same path. Express dispatches in registration order, so the first wrapper — closing over the old module's handler — kept winning. Inbound traffic (e.g. Teams /api/messages, Adaptive-Card Action.Submit) ran stale code until a full process restart, while the proactive sender (keyed + disposed via NotificationRouter) hot-swapped correctly — the split-brain reported in #395.

Why

A reinstall appeared to take (cards/UI updated) but inbound behaviour silently ran the previous module. It was easy to mis-diagnose as a parsing/contract bug rather than a stale-module bug, and the only fix was fly machine restart.

How it works

The registry now keys routes in a Map<"${method} ${path}", RouteMount> and mounts each route/router once. The Express wrapper closes over a mutable per-route record and reads mount.handler on each dispatch, so a same-channel re-registration rebinds in place (logged as route rebound …) rather than stacking a shadowed mount. Every hot-swap path (config reactivate, version upload) deactivates then re-activates the channel, which re-runs activate() and lands on this rebind branch. registerRouter gets the same treatment symmetrically, and a different channel claiming an already-owned path/prefix is now rejected with a throw instead of silently transferring ownership. describe() reports one entry per path regardless of re-registration count.

Tests

  • New regression test middleware/test/channelRouteRebind.test.ts (4 cases, real Express + fetch): rebind serves the reinstalled handler, no second mount is stacked, cross-channel ownership is rejected, and a deactivated channel is gated with 503 until re-activation. Passes 4/4.
  • Verified against a dummy channel plugin driving the real kernel wiring (ExpressRouteRegistry, createCoreApi, NotificationRouter): pre-fix the inbound path reports v1 (stale) while the proactive sender reports v2; post-fix both report v2.
  • eslint clean on the changed file.

Backward compatibility

Behaviour-preserving for the normal register-once path. No manifest, API, env-var, or schema changes.

Closes #395

…#395)

ExpressRouteRegistry mounted a fresh Express route on every register().
A channel plugin hot-reinstall re-runs activate() -> core.registerRoute(),
which pushed a second app.<method>(path) for the same path. Express
dispatches in registration order, so the first wrapper -- closing over the
old module's handler -- kept winning. Inbound traffic (e.g. Teams
/api/messages, Adaptive-Card Action.Submit) silently ran stale code until a
full process restart, while the proactive sender hot-swapped correctly.

Mount each route/router once and store the handler in a mutable per-route
record; re-registration rebinds in place instead of stacking a shadowed
mount. A different channel claiming an already-owned path is rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Channel hot-reinstall swaps the proactive sender but not the inbound handler (stale module until process restart)

1 participant