Skip to content

fix: recover stale source peer that breaks forwards with CHANNEL_INVALID#10

Merged
awdr74100 merged 1 commit into
mainfrom
fix/source-peer-min-recovery
Jul 10, 2026
Merged

fix: recover stale source peer that breaks forwards with CHANNEL_INVALID#10
awdr74100 merged 1 commit into
mainfrom
fix/source-peer-min-recovery

Conversation

@awdr74100

Copy link
Copy Markdown
Owner

Problem

A forward source referenced by numeric channel id — a private channel
with no @username, e.g. -1001727806995 — can only be forwarded from
when the session storage holds a full, non-min access hash. That hash
is only obtainable via a dialog scan (getDialogs), and it silently decays
to min between runs. resolvePeer cannot recover a full hash on its own
for such a peer (it falls back to channels.getChannels with a zero access
hash → CHANNEL_INVALID).

The result: after restarting (classically "the next day"), every forward
from that source fails with CHANNEL_INVALID on repeat, and the only fix
was to manually re-run group add / group edit — whose only effective
action is the dialog scan they happen to perform (group remove does not
touch the peer cache at all). checkPeers already warmed the cache, but
only conditionally, and its resolveState first pass cannot tell a decayed
min hash 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":

  • Start-time warm (checkPeers). Warm the cache unconditionally on
    start whenever any source/target is a private numeric channel (marked
    -100…), instead of trusting a resolve that can look valid yet still fail
    the real forward. A username-only setup resolves over the network and
    still skips the scan.
  • Runtime self-heal (forwardOne + recoverSource). When a forward
    fails with an invalid-peer error, probeTarget disambiguates: if the
    target 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 cannot
    launch 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.
    • add recoverSource (throttled dialog-scan warm) and lastSourceWarmAt.
    • add isPrivateNumericPeer helper and SOURCE_WARM_COOLDOWN_MS.
    • rename isInvalidTargetErrorisInvalidPeerError (it now classifies
      invalid-peer errors for both source and target).

Tests

  • isInvalidPeerError: renamed; existing coverage retained.
  • checkPeers: username-only setup still skips the scan; a new test
    asserts a private numeric channel always warms even when it resolves
    cleanly from cache.

pnpm typecheck, pnpm lint, pnpm test (62 passed), pnpm build, and
pnpm knip all pass.

Note: recoverSource orchestration inside forwardOne is not directly
unit-tested (it requires driving a real Dispatcher + RPC, which this repo
deliberately does not mock). The pure logic it relies on is covered, and
the diagnosis was verified against the real session.sqlite (source
stored isMin=1, no username, no usable message reference).

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 awdr74100 merged commit ac56a09 into main Jul 10, 2026
2 checks passed
@awdr74100 awdr74100 deleted the fix/source-peer-min-recovery branch July 10, 2026 16:54
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.
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.

1 participant