fix: recover stale source peer that breaks forwards with CHANNEL_INVALID#10
Merged
Conversation
A source referenced by numeric channel id (a private channel with no @username) can only be forwarded from when storage holds a full, non-min access hash. That hash is only obtainable via a dialog scan and silently decays to "min" between runs, so after a restart every forward fails with CHANNEL_INVALID until the user manually re-runs `group add`/`group edit` (whose only effective action is the dialog scan they perform). Two-layer fix: - checkPeers warms the cache unconditionally on start whenever any source/target is a private numeric channel (marked -100…), instead of trusting a first-pass resolve that can look valid yet still fail the real forward once the stored hash has gone min. - forwardOne treats an invalid-peer error whose target still resolves as a stale source: it re-warms the cache once (throttled to one scan per cooldown so a burst of failures cannot trigger a scan each) and retries, so a mid-session decay self-heals without manual intervention. Rename isInvalidTargetError -> isInvalidPeerError since it now classifies invalid-peer errors for both the source and the target.
awdr74100
added a commit
that referenced
this pull request
Jul 11, 2026
…ally takes effect The v0.4.0 recovery (#10) re-warmed mtcute's peer cache after a CHANNEL_INVALID forward failure, but the retry still failed identically. Two-layer root cause, found by tracing mtcute 0.30.3 internals: - forwardMessages({ messages }) sends messages[0].chat.inputPeer — the peer snapshot embedded in the Message when the update arrived — and resolvePeer passes input peers through untouched. The retry therefore resent the exact same stale/min access hash and never touched the freshly warmed cache. - mtcute's PeersService caches "min" access hashes over full ones whenever updates carry min channel entities, and its LRU getById path skips the isMin check. So a peer that worked at startup silently breaks again the moment the source posts — re-warming alone can never stick. (This is also what the recurring "error fetching difference: 400 CHANNEL_INVALID" update-manager warnings are.) Fix: capture every full (non-min) input peer straight off the dialog scans we already run (startup checkPeers + recoverSource) into the forwarder's own warmPeers map, and forward with forwardMessagesById, resolving both source and target through that map. Forwards no longer depend on mtcute's poisonable cache nor on the Message-embedded peer snapshot, so the recovery loop is closed: fail → rescan → retry with the newly captured hash.
awdr74100
added a commit
that referenced
this pull request
Jul 11, 2026
…ally takes effect (#13) The v0.4.0 recovery (#10) re-warmed mtcute's peer cache after a CHANNEL_INVALID forward failure, but the retry still failed identically. Two-layer root cause, found by tracing mtcute 0.30.3 internals: - forwardMessages({ messages }) sends messages[0].chat.inputPeer — the peer snapshot embedded in the Message when the update arrived — and resolvePeer passes input peers through untouched. The retry therefore resent the exact same stale/min access hash and never touched the freshly warmed cache. - mtcute's PeersService caches "min" access hashes over full ones whenever updates carry min channel entities, and its LRU getById path skips the isMin check. So a peer that worked at startup silently breaks again the moment the source posts — re-warming alone can never stick. (This is also what the recurring "error fetching difference: 400 CHANNEL_INVALID" update-manager warnings are.) Fix: capture every full (non-min) input peer straight off the dialog scans we already run (startup checkPeers + recoverSource) into the forwarder's own warmPeers map, and forward with forwardMessagesById, resolving both source and target through that map. Forwards no longer depend on mtcute's poisonable cache nor on the Message-embedded peer snapshot, so the recovery loop is closed: fail → rescan → retry with the newly captured hash.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A forward source referenced by numeric channel id — a private channel
with no
@username, e.g.-1001727806995— can only be forwarded fromwhen the session storage holds a full, non-min access hash. That hash
is only obtainable via a dialog scan (
getDialogs), and it silently decaysto
minbetween runs.resolvePeercannot recover a full hash on its ownfor such a peer (it falls back to
channels.getChannelswith a zero accesshash →
CHANNEL_INVALID).The result: after restarting (classically "the next day"), every forward
from that source fails with
CHANNEL_INVALIDon repeat, and the only fixwas to manually re-run
group add/group edit— whose only effectiveaction is the dialog scan they happen to perform (
group removedoes nottouch the peer cache at all).
checkPeersalready warmed the cache, butonly conditionally, and its
resolveStatefirst pass cannot tell a decayedminhash from a good one — so it skipped the scan and the forward failed.What changed
Two layers, both boiling down to "make sure the source is warmed to a full
access hash":
checkPeers). Warm the cache unconditionally onstart whenever any source/target is a private numeric channel (marked
-100…), instead of trusting a resolve that can look valid yet still failthe real forward. A username-only setup resolves over the network and
still skips the scan.
forwardOne+recoverSource). When a forwardfails with an invalid-peer error,
probeTargetdisambiguates: if thetarget still resolves and was not migrated, the bad peer is the source
— so re-warm the cache once and retry. The scan is throttled to one per
SOURCE_WARM_COOLDOWN_MS(60s) so a burst of failing forwards cannotlaunch a dialog scan each (which would risk FloodWait). Because the queue
is serialized, the first message's warm usually repairs storage and the
rest succeed on their first attempt. No more manual
group edit.The existing basic-group → supergroup target migration path is
unchanged; source recovery slots into the branch that previously just
re-raised the error.
Details
forwarder.ts:checkPeers: force a warm when any peer is a private numeric channel.forwardOne: on a healthy target, attempt source recovery + retry.recoverSource(throttled dialog-scan warm) andlastSourceWarmAt.isPrivateNumericPeerhelper andSOURCE_WARM_COOLDOWN_MS.isInvalidTargetError→isInvalidPeerError(it now classifiesinvalid-peer errors for both source and target).
Tests
isInvalidPeerError: renamed; existing coverage retained.checkPeers: username-only setup still skips the scan; a new testasserts a private numeric channel always warms even when it resolves
cleanly from cache.
pnpm typecheck,pnpm lint,pnpm test(62 passed),pnpm build, andpnpm knipall pass.