Skip to content

Commit bd261b1

Browse files
Музыка: одна сессия на все вызовы и бэкофф вместо часа
Троттлинг Ynison замерен, а не угадан: свежий device id получает 503 на первом же заходе, а Яндекс.Музыка на том же телефоне в это же время играет. Значит блокируется токен нашего клиента, и каждый заход во время троттлинга снова взводит час - прежняя формулировка про "бан по device id" была неверна. - сессия переиспользуется (SESSION_TTL_MS 10 мин), кадры копятся в живом массиве с хвостом, readState читает последний кадр, а не первый; - параллельные вызовы ждут один диал (openingSession); - бэкофф удваивается: 1/2/4/8/12 ч, счётчик attempts лежит в ynison-goaway.json, успешная сессия его сбрасывает; - ynison-session.test.mjs переписан под новый контракт, новый ynison-session-cache.test.mjs (11 тестов), jstest зелёный.
1 parent 26a5fc1 commit bd261b1

6 files changed

Lines changed: 574 additions & 45 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,39 @@ Play: листинг, скриншоты, политика конфиденци
237237
ли уже» — это и есть способ не дождаться никогда: каждый замер стоит ещё час. Сквозной
238238
прогон требует часа полной тишины, включая `music_status`.
239239

240+
Музыка: троттлинг следует за токеном, а не за устройством — одна сессия и бэкофф (26.10.2026)
241+
- Разбор «что именно забанено» замером, а не догадкой. Пять точек за 20 минут:
242+
id телефона → 503 `500140000` (+3600 с); **новый** id с ПК → сессия открылась; тот же
243+
новый id вторым заходом → 503; **ещё один новый** id первым заходом → 503; при этом
244+
Яндекс.Музыка на том же телефоне играет (`state=PLAYING`).
245+
- Отсюда три вывода. Бан **не** по device id (свежий id получил отказ с первого захода),
246+
**не** по аккаунту и не авария сервиса (приложение играет тем же аккаунтом), а троттлится
247+
**OAuth-токен нашего клиента**, и любой заход во время троттлинга снова взводит час.
248+
Прежняя формулировка в коде «ban counted per device id» была неверной — исправлена.
249+
- Причина троттлинга уже была найдена раньше: 1632 обрыва MCP-транспорта между 21:35 и
250+
04:28, каждый обрыв = новый handshake. Транспорт починен, но счётчик Yandex сам не обнулился.
251+
- **Одна сессия на все вызовы** (`SESSION_TTL_MS` = 10 мин). Раньше каждый тул-вызов
252+
открывал свой сокет, а `music_play_query` — ещё и второй на верификацию: один запрос
253+
стоил 2–3 захода. Теперь заход один, сокет переиспользуется, кадры копятся в живом
254+
массиве (хвост 120, последнее состояние не теряется), а `readState` читает **последний**
255+
кадр — иначе на живой сессии он показывал бы трек, который играл при её открытии.
256+
- **Параллельные вызовы не дублируют заход** (`openingSession`): два тула в одном батче
257+
ждут один диал.
258+
- **Бэкофф с удвоением** (потолок 12 ч): час, два, четыре, восемь, двенадцать. Счётчик
259+
`attempts` лежит в `ynison-goaway.json` рядом с дедлайном, поэтому рестарт процесса не
260+
обнуляет его и не возвращает часовой цикл. Успешная сессия счётчик сбрасывает.
261+
- Тесты: `ynison-session.test.mjs` (5) переписан под контракт «сокет остаётся жить»,
262+
новый `ynison-session-cache.test.mjs` (11) — переиспользование, TTL, мёртвый сокет, бан
263+
на живой сессии, удвоение, потолок, двойной кадр одного захода, сброс после успеха,
264+
параллельные вызовы. `-Task jstest` зелёный.
265+
- Проверено на телефоне, что при этом работает **без** Ynison: `session_control` через
266+
MediaSession — `pause` → `paused, not playing`, `play` → `playing`, трек и позиция те же.
267+
Недоступен только выбор произвольного трека.
268+
- Состояние: троттлинг активен, `ynison-goaway.json` на устройстве вручную отодвинут на
269+
6 часов (до ~12:08), чтобы старый код не взвёл его снова. Замер «Playback >100 с» —
270+
после этого срока: один `music_play_query`, дальше опрос MediaSession через `dumpsys`,
271+
без `music_status` (он идёт в Ynison и снова взведёт час).
272+
240273
Музыка: 180 с ни при чём, вызов умирает на go-away 503 (25.10.2026)
241274
- Замер P1 на CPH2747: `music_play_query` при убитой Яндекс.Музыке падает за **18,5 с**
242275
с `no player_state after handshake`, `ensurePhone` не доходит вообще (приложение так

‎app/src/main/assets/mcp/ynison.js‎

Lines changed: 154 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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".
592597
const 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+
604631
function 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

615655
function 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

800861
function 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.
899971
const 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+
9011008
async 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) {
15271658
async 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

Comments
 (0)