@@ -180,6 +180,8 @@ class YnisonWs {
180180 this . fragments = [ ] ;
181181 this . fragmentOpcode = 0x1 ;
182182 this . settled = false ;
183+ // Read by the session cache: a socket the server closed must not be handed out again.
184+ this . closed = false ;
183185 }
184186
185187 connect ( ) {
@@ -356,6 +358,7 @@ class YnisonWs {
356358 }
357359
358360 close ( ) {
361+ this . closed = true ;
359362 if ( this . socket && this . upgraded ) {
360363 try {
361364 this . writeFrame ( 0x8 , Buffer . alloc ( 0 ) ) ;
@@ -369,6 +372,7 @@ class YnisonWs {
369372 // Used by the redirect path: the promise is already settled, so the error has to travel
370373 // through onerror instead of rejecting.
371374 terminate ( ) {
375+ this . closed = true ;
372376 this . socket ?. end ( ) ;
373377 }
374378
@@ -382,6 +386,7 @@ class YnisonWs {
382386 }
383387
384388 onSocketClose ( ) {
389+ this . closed = true ;
385390 this . onclose ?. ( ) ;
386391 }
387392}
@@ -580,37 +585,72 @@ function errorDetail(payload) {
580585 return nested && nested [ "ynison-error-code" ] ? nested : null ;
581586}
582587
583- // When the server says stay away, it means the whole process , not this one call: the ban is
584- // counted per device id, and every tool call that opens a socket anyway is another connection
585- // from the id being penalised. The deadline is in memory because the MCP server outlives the
586- // call, and it is a deadline rather than a flag so an expired ban lifts by itself.
588+ // When the server says stay away, it means the whole client , not this one call: every tool
589+ // call that opens a socket anyway is another dial from the token that is being throttled.
590+ // The deadline is in memory because the MCP server outlives the call, and it is a deadline
591+ // rather than a flag so an expired ban lifts by itself.
587592//
588593// It is also written next to the state cache, because the process is not as long-lived as the
589594// ban: measured 25.10.2026, the server restarted at 21:57 and the deadline set before that was
590595// gone, so the first call afterwards opened a socket and collected a fresh 3600s. A restart
591596// must not turn "wait out the ban" into "ask for another one".
592597const GO_AWAY_FILE = STATE_CACHE ? STATE_CACHE . replace ( / [ ^ \\ / ] * $ / , "" ) + "ynison-goaway.json" : "" ;
593598
594- function loadGoAway ( now = Date . now ( ) ) {
595- if ( ! GO_AWAY_FILE ) return 0 ;
599+ // The server's "go away for 3600s" is not how long it actually stays angry. Measured
600+ // 25.10-26.10.2026: the client retried exactly at the announced deadline and got a fresh
601+ // 3600s each time - at 02:56, at 03:58, at 05:54. An hourly retry is therefore not a retry,
602+ // it is a way to keep the throttle alive, because every dial re-arms it. So the wait doubles
603+ // after each ban that arrives despite the previous one having run out: 1h, 2h, 4h, 8h, and
604+ // 12h from there on. A session that opens proves the ban is over and resets it.
605+ const GO_AWAY_MAX_MS = 12 * 60 * 60 * 1000 ;
606+ // One attempt can report the ban twice (the redirect frame, then the state frame). Counting
607+ // each would double the wait for a single connection, so a ban seen within a minute of the
608+ // previous one only extends the deadline.
609+ const GO_AWAY_RECORD_GAP_MS = 60 * 1000 ;
610+
611+ function loadGoAwayState ( ) {
612+ if ( ! GO_AWAY_FILE ) return { until : 0 , attempts : 0 } ;
596613 try {
597- const until = Number ( JSON . parse ( readFileSync ( GO_AWAY_FILE , "utf8" ) ) . until ) || 0 ;
598- return until > now ? until : 0 ;
614+ const saved = JSON . parse ( readFileSync ( GO_AWAY_FILE , "utf8" ) ) ;
615+ return { until : Number ( saved . until ) || 0 , attempts : Math . max ( 0 , Number ( saved . attempts ) || 0 ) } ;
599616 } catch {
600- return 0 ; // нет файла, битый файл, нет прав - бана просто нет
617+ return { until : 0 , attempts : 0 } ; // нет файла, битый файл, нет прав - бана просто нет
601618 }
602619}
603620
621+ const savedGoAway = loadGoAwayState ( ) ;
622+
623+ function loadGoAway ( now = Date . now ( ) ) {
624+ return savedGoAway . until > now ? savedGoAway . until : 0 ;
625+ }
626+
627+ let goAwayUntil = loadGoAway ( ) ;
628+ let goAwayAttempts = savedGoAway . attempts ;
629+ let goAwayRecordedAt = 0 ;
630+
604631function saveGoAway ( until ) {
605632 if ( ! GO_AWAY_FILE ) return ;
606633 try {
607- writeFileSync ( GO_AWAY_FILE , JSON . stringify ( { until } ) ) ;
634+ writeFileSync ( GO_AWAY_FILE , JSON . stringify ( { until, attempts : goAwayAttempts } ) ) ;
608635 } catch {
609636 /* the ban still holds in memory; losing it costs one extra connection */
610637 }
611638}
612639
613- let goAwayUntil = loadGoAway ( ) ;
640+ /** The server's interval, doubled once per consecutive ban. */
641+ function goAwayBackoffMs ( seconds , attempts ) {
642+ const base = seconds * 1000 ;
643+ const scaled = base * Math . pow ( 2 , Math . max ( 0 , attempts - 1 ) ) ;
644+ return Math . min ( scaled , Math . max ( base , GO_AWAY_MAX_MS ) ) ;
645+ }
646+
647+ /** A session that opened proves the ban is over; the next ban starts from one hour again. */
648+ function clearGoAway ( ) {
649+ if ( ! goAwayUntil && ! goAwayAttempts ) return ;
650+ goAwayUntil = 0 ;
651+ goAwayAttempts = 0 ;
652+ saveGoAway ( 0 ) ;
653+ }
614654
615655function goAwayError ( payload ) {
616656 const detail = errorDetail ( payload ) ;
@@ -621,18 +661,28 @@ function goAwayError(payload) {
621661 // a 503 without one is a plain server error and must stay retryable.
622662 if ( code !== "400090001" && ! seconds ) return null ;
623663 const secs = seconds || "some" ;
624- if ( seconds ) {
625- goAwayUntil = Math . max ( goAwayUntil , Date . now ( ) + seconds * 1000 ) ;
664+ const now = Date . now ( ) ;
665+ if ( seconds && now - goAwayRecordedAt > GO_AWAY_RECORD_GAP_MS ) {
666+ goAwayRecordedAt = now ;
667+ goAwayAttempts += 1 ;
668+ }
669+ const waitMs = seconds ? goAwayBackoffMs ( seconds , goAwayAttempts || 1 ) : 0 ;
670+ if ( waitMs ) {
671+ goAwayUntil = Math . max ( goAwayUntil , now + waitMs ) ;
626672 saveGoAway ( goAwayUntil ) ;
627673 }
628674 const reason = ( payload . error && payload . error . message ) || "" ;
675+ const doubled = waitMs > seconds * 1000 ? ` #${ goAwayAttempts } in a row, so the wait doubles` : "" ;
629676 const e = new Error (
630677 code === "400090001"
631- ? `Yandex Music dropped this device and asked it to stay away ${ secs } s (ynison 400090001). ` +
632- `The phone goes offline when too many Ynison sessions are opened under one device id. ` +
633- `The token is valid - do not re-login. Wait out the interval before trying again.`
634- : `Ynison asked this device to stay away ${ secs } s (ynison ${ code } ${ reason ? `: ${ reason } ` : "" } ). ` +
635- `The token is valid - do not re-login. Wait out the interval before trying again.` ,
678+ ? `Yandex Music dropped this device and asked it to stay away ${ secs } s (ynison 400090001${ doubled } ). ` +
679+ `Ynison throttles the account's token when one client dials too often, and every dial ` +
680+ `while it is throttled re-arms the interval. ` +
681+ `The token is valid - do not re-login. ` +
682+ ( waitMs ? `Next attempt is held back for ${ Math . round ( waitMs / 60000 ) } min - retrying sooner only re-arms the ban.` : "" )
683+ : `Ynison asked this device to stay away ${ secs } s (ynison ${ code } ${ reason ? `: ${ reason } ` : "" } ${ doubled } ). ` +
684+ `The token is valid - do not re-login. A new device id does not help: the throttle follows the token. ` +
685+ ( waitMs ? `Next attempt is held back for ${ Math . round ( waitMs / 60000 ) } min - retrying sooner only re-arms the ban.` : "" ) ,
636686 ) ;
637687 e . permanent = true ;
638688 e . goAwaySeconds = seconds ;
@@ -727,6 +777,17 @@ async function openSessionOnce(seed = null) {
727777 const parsed = JSON . parse ( e . data ) ;
728778 trace ( "state frame keys:" , Object . keys ( parsed ) . join ( "," ) || "(none)" , String ( e . data ) . slice ( 0 , 300 ) ) ;
729779 frames . push ( parsed ) ;
780+ // A reused session lives for minutes, so the array must keep a tail, not a history.
781+ // The last real state is re-inserted when it falls out of that tail: the newest frames
782+ // can be device noise, and readState would then report "no player_state" on a session
783+ // that plainly has one.
784+ if ( frames . length > SESSION_MAX_FRAMES ) {
785+ const dropped = frames . splice ( 0 , frames . length - SESSION_MAX_FRAMES ) ;
786+ if ( ! frames . some ( ( f ) => f . player_state ) ) {
787+ const lastState = dropped . filter ( ( f ) => f . player_state ) . pop ( ) ;
788+ if ( lastState ) frames . unshift ( lastState ) ;
789+ }
790+ }
730791 // Remember it. The next session's handshake has to publish a valid state before it
731792 // has seen any, and the only state worth publishing is a real one.
732793 if ( parsed . player_state ) saveCachedState ( parsed . player_state ) ;
@@ -798,7 +859,16 @@ async function openSessionOnce(seed = null) {
798859}
799860
800861function readState ( frames ) {
801- const frame = frames . find ( ( f ) => f . player_state ) ;
862+ // The LAST state frame, not the first. A session is reused for minutes, so the earliest
863+ // player_state in the array is the oldest news there is - reporting it would show the
864+ // track that was playing when the session opened, not the one playing now.
865+ let frame = null ;
866+ for ( let i = frames . length - 1 ; i >= 0 ; i -- ) {
867+ if ( frames [ i ] . player_state ) {
868+ frame = frames [ i ] ;
869+ break ;
870+ }
871+ }
802872 if ( ! frame ) return null ;
803873 const s = frame . player_state ;
804874 const q = s . player_queue ;
@@ -887,7 +957,9 @@ async function waitForState(expect, attempts = VERIFY_ATTEMPTS) {
887957 }
888958 return null ;
889959 } finally {
890- ws . close ( ) ;
960+ // The session stays open for the next call on purpose: see SESSION_TTL_MS. Only a socket
961+ // the server already closed is dropped, so the next call does not reuse a dead one.
962+ if ( ws . closed ) dropSession ( "closed during verify" ) ;
891963 }
892964}
893965
@@ -898,11 +970,68 @@ async function waitForState(expect, attempts = VERIFY_ATTEMPTS) {
898970// the message happens to be worded.
899971const PERMANENT_OPEN_ERRORS = [ "is not set" , "refused the token" ] ;
900972
973+ // One session, reused by every call that follows it.
974+ //
975+ // This is not an optimisation, it is what keeps music usable at all. Ynison throttles the
976+ // OAuth token, and the count of dials that earns a 3600s "stay away" is small - one
977+ // `music_play_query` alone (handoff, then the verify pass) was enough on 26.10.2026. A fresh
978+ // device id is no way out either: measured at 06:12 the same token was refused on the FIRST
979+ // dial of an id that had never connected before. So the number of dials is the defect, and a
980+ // real client dials once and stays.
981+ //
982+ // The TTL exists because an idle socket is eventually dropped by the server or the network,
983+ // and a stale-but-open socket is worse than a fresh one. Frames are capped for the same
984+ // reason: a session that lives for hours must not grow an unbounded array.
985+ const SESSION_TTL_MS = 10 * 60 * 1000 ;
986+ const SESSION_MAX_FRAMES = 120 ;
987+
988+ let liveSession = null ;
989+ // Set while a dial is in flight, so a second caller waits instead of dialling again.
990+ let openingSession = null ;
991+
992+ function sessionUsable ( session , now = Date . now ( ) ) {
993+ return Boolean ( session ) && ! session . ws ?. closed && now - session . openedAt < SESSION_TTL_MS ;
994+ }
995+
996+ function dropSession ( why ) {
997+ const session = liveSession ;
998+ if ( ! session ) return ;
999+ liveSession = null ;
1000+ trace ( "session dropped:" , why , "after" , session . frames . length , "frame(s)" ) ;
1001+ try {
1002+ session . ws ?. close ?. ( ) ;
1003+ } catch {
1004+ /* socket already gone - nothing to close politely */
1005+ }
1006+ }
1007+
9011008async function openSession ( seed = null ) {
1009+ if ( liveSession ) {
1010+ if ( sessionUsable ( liveSession ) ) {
1011+ // A ban can also arrive on a live socket, as a frame rather than a close.
1012+ throwIfGoAwayFrame ( liveSession . frames , liveSession . ws ) ;
1013+ trace ( "reusing session, age" , Math . round ( ( Date . now ( ) - liveSession . openedAt ) / 1000 ) + "s" ) ;
1014+ return liveSession ;
1015+ }
1016+ dropSession ( "expired or closed by server" ) ;
1017+ }
1018+ // Two tool calls can arrive at once (the agent may ask for status and playback in one
1019+ // batch), and two concurrent dials are exactly the pattern being throttled. The second
1020+ // caller waits for the first dial instead of starting one of its own.
1021+ if ( ! openingSession ) {
1022+ openingSession = dialSession ( seed ) . finally ( ( ) => { openingSession = null ; } ) ;
1023+ }
1024+ return openingSession ;
1025+ }
1026+
1027+ async function dialSession ( seed ) {
9021028 let lastError = null ;
9031029 for ( let attempt = 0 ; attempt < OPEN_ATTEMPTS ; attempt ++ ) {
9041030 try {
905- return await openSessionOnce ( seed ) ;
1031+ const session = await openSessionOnce ( seed ) ;
1032+ liveSession = { ws : session . ws , frames : session . frames , openedAt : Date . now ( ) } ;
1033+ clearGoAway ( ) ;
1034+ return liveSession ;
9061035 } catch ( e ) {
9071036 lastError = e ;
9081037 trace ( "open attempt" , attempt , "failed:" , e . message ) ;
@@ -1045,7 +1174,9 @@ async function handoff(mutate, seed = null, launched = { already: false }) {
10451174 if ( phoneId ) send ( ws , { ...meta ( ) , update_active_device : { device_id_optional : phoneId } } ) ;
10461175 await wait ( CLAIM_SETTLE_MS ) ;
10471176 } catch { /* socket already gone; nothing left to strand */ }
1048- ws . close ( ) ;
1177+ // The socket is NOT closed here: the verify pass that follows and every later tool call
1178+ // reuse it, which is the whole point of the session cache.
1179+ if ( ws . closed ) dropSession ( "closed during handoff" ) ;
10491180 }
10501181 return phoneId ;
10511182}
@@ -1527,7 +1658,7 @@ async function opPlayQuery(query, artist) {
15271658async function opStatus ( ) {
15281659 const { ws, frames } = await openSession ( ) ;
15291660 const state = readState ( frames ) ;
1530- ws . close ( ) ;
1661+ if ( ws . closed ) dropSession ( "closed during status" ) ;
15311662 if ( ! state ) throw new Error ( "no player_state" ) ;
15321663 const phone = findPhone ( state ) ;
15331664 const current = state . queue ?. playable_list ?. [ state . index ] ;
0 commit comments