package channelbridge // This file holds the hot-reload machinery for the channel bridge (design/04 // §3.1). The panel saves a platform's account config to channels.json and calls // Bridge.Reload(platform): the factory rebuilds that platform's adapters, they // connect first (new messages use the new config), then the old adapters are // gracefully stopped. A message already in flight finishes on its old adapter // because processing is spawned with the bridge-lifetime context, not the // per-adapter read context that reload cancels. import ( "context" "errors" "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // PlatformFactory rebuilds the adapters for a platform from its current effective // account config (channels.json, falling back to env). It is injected so the // bridge can hot-replace a platform's adapters when the panel saves a new config // without a restart. The concrete implementation lives in the composition root // (cmd/cyrene), which owns the platform adapter constructors. type PlatformFactory interface { // Build returns the adapters for platform from its effective account config. // The returned slice may be empty when the platform has no configured account. Build(platform string) ([]adapter.PlatformAdapter, error) } // ErrNilFactory is returned when a reload is requested without a platform factory // injected (no hot-reload configuration source). var ErrNilFactory = errors.New("channelbridge: nil platform factory") // ErrBridgeClosed is returned when a reload is requested on a stopped bridge. var ErrBridgeClosed = errors.New("channelbridge: bridge is not running") // connectAndRun dials a single adapter (bound by connectTimeout) and, on success, // installs its inbound handler and launches its read loop under a per-adapter // context. The per-adapter context is what a hot reload cancels to stop the old // read loop; message processing is spawned with the bridge-lifetime context so an // in-flight message started before a Reload completes on its old adapter. func (b *Bridge) connectAndRun(a adapter.PlatformAdapter) { if a == nil { return } if !b.configured(a) { b.log.Warn("channel skipped: no credentials", "channel", a.Name()) return } connCtx, cancel := context.WithTimeout(context.Background(), connectTimeout) err := a.Connect(connCtx) cancel() if err != nil { b.log.Warn("channel connect failed, leaving disconnected", "channel", a.Name(), "error", err) return } // Close over the adapter so the inbound callback knows which account received // the message — required for multi-account reply routing and for reading that // account's allow/deny lists (design/16 §4.6 §5). a.SetMessageHandler(func(_ context.Context, msg adapter.UnifiedMessage) { b.onMessage(b.ctx, msg, a) }) b.mu.Lock() if b.closed { b.mu.Unlock() ctx, c := context.WithTimeout(context.Background(), connectTimeout) _ = a.Disconnect(ctx) c() return } readCtx, rcancel := context.WithCancel(b.ctx) b.readCancels[a.Name()] = rcancel b.connected[a.Name()] = true b.mu.Unlock() b.wg.Add(1) go b.readLoop(a, readCtx) b.log.Info("channel connected", "channel", a.Name()) } // Reload rebuilds the adapters for a platform from its current effective config // (via the injected factory) and hot-replaces the running ones without a restart // (design/04 §3.1). The rebuilt adapters connect first; messages that arrived on // the old adapters finish on them, and new messages are picked up by the new // configuration. An account that was removed stops its old adapter, and an // unchanged-name account stops the old adapter once the new one is live. func (b *Bridge) Reload(platform string) error { if b.factory == nil { return ErrNilFactory } b.mu.Lock() started := b.started closed := b.closed b.mu.Unlock() if !started || closed { return ErrBridgeClosed } newAdapters, err := b.factory.Build(platform) if err != nil { return fmt.Errorf("channelbridge: build %s: %w", platform, err) } old, oldCancels := b.adapterSnap(platform) newNames := make(map[string]bool, len(newAdapters)) for _, a := range newAdapters { newNames[a.Name()] = true } for _, a := range newAdapters { b.connectAndRun(a) } for _, o := range old { if newNames[o.Name()] { // Same-name account replaced by the rebuilt adapter: stop the old read // loop but leave the registry entry — it now points at the new adapter. b.cancelAndDisconnect(o, oldCancels[o.Name()]) continue } // Account removed (or renamed) entirely: stop it and drop its registry // entries, so a subsequent snapshot no longer sees it. b.removeAdapter(o, oldCancels[o.Name()]) } b.log.Info("channel reloaded", "platform", platform, "adapters", len(newAdapters)) return nil } // adapterSnap returns the adapters currently registered for a platform, together // with the context-cancel func that controls each one's read loop. It is used by // Reload to stop the old adapters after the rebuilt ones are live. func (b *Bridge) adapterSnap(platform string) ([]adapter.PlatformAdapter, map[string]context.CancelFunc) { b.mu.RLock() defer b.mu.RUnlock() var list []adapter.PlatformAdapter cancels := make(map[string]context.CancelFunc) for name, a := range b.adapters { if basePlatform(name) != platform { continue } list = append(list, a) if c, ok := b.readCancels[name]; ok { cancels[name] = c } } return list, cancels } // cancelAndDisconnect stops an adapter's read loop and tears down its session. // It never touches the registry map: callers that replace a same-name adapter // want the (new) entry to stay, and callers that remove an account use // removeAdapter. func (b *Bridge) cancelAndDisconnect(o adapter.PlatformAdapter, c context.CancelFunc) { if c != nil { c() } ctx, cancel := context.WithTimeout(context.Background(), connectTimeout) _ = o.Disconnect(ctx) cancel() b.log.Info("channel (reload) stopped", "channel", o.Name()) } // removeAdapter stops an account's adapter and drops its registry entries. It is // only called for names that were NOT rebuilt by Reload, so the delete can never // remove a replacement adapter that now owns the name. func (b *Bridge) removeAdapter(o adapter.PlatformAdapter, c context.CancelFunc) { b.cancelAndDisconnect(o, c) name := o.Name() b.mu.Lock() delete(b.adapters, name) delete(b.connected, name) delete(b.readCancels, name) b.mu.Unlock() b.log.Info("channel (reload) removed", "channel", name) }