Skip to content

A face-to-face room's history fails with \"Maximum call stack size exceeded (gateway/internal)\" on both homes #979

Closed

Description

@Birfy

Found while running #977 (the in-room approval card) on main 90f4bb5a, two isolated installs, real UI, one face-to-face room with both seats in it.

Steps

  1. Home A opens a room with a 6-digit number; home B joins with the same number (both Bots seated, room frm_01M5Q12SJ9K380478GVN5A3B49).
  2. A starts the room (face-start).
  3. Either side opens the room from the sidebar.

Expected

The room's transcript renders — the "joined; available to this room only" lines, "The room is now open. The number is no longer valid.", and the room's lines.

Actual

Both sides show a failure line where the transcript should be:

Failed to load history: Maximum call stack size exceeded (gateway/internal)

and the page raises the same error twice:

pageerror: Maximum call stack size exceeded
pageerror: Maximum call stack size exceeded

Scope

Any face-to-face room opened in the UI on this tip. Not related to the approval card itself: the card renders fine (Waiting for approval / Chief (Selftest B2) → the room / Approve Reject Auto-send from now on), it is the transcript around it that fails.

Written-by: SelfTest

Activity

  1. added
    bugSomething isn't working
    area:socialFriends, invites, grants, Bot Social
    area:uiWeb/desktop UI and copy
    self-testFiled by the product self-test routine (issues only)
    on Oct 24, 2026
  2. Birfy commented on Oct 24, 2026

    @Birfy
    CollaboratorAuthor

    Triage: real, reproduced five loads across two homes, and not a duplicate (the only earlier call stack report, #677, was the tree's heightOf cycle guard, closed and unrelated). bug|area:ui|area:social|self-test kept, agent-ready added.

    Where the failing call enters

    The shell prefixes Failed to load history: and the suffix (gateway/internal) is the gateway mapping any thrown error, so the code is not the diagnosis — the thrown value is a RangeError (deep recursion), not a JSON cycle (TypeError: Converting circular structure to JSON). The path:

    • core/src/host/channel.js:368-371 — case 'room-history' calls rt.roomHistory(...); it is in the READS set at :217.
    • core/src/host/room-membership.js:818-826 — rt.roomHistory → roomVisibleTo (authz) then linesVisibleTo.
    • core/src/host/room-membership.js:302-305 — linesVisibleTo itself is a plain filter; the recursion is in something it and its callers touch, not in this function.

    Note there are two stacks to explain, not one: the gateway error is host-side, and pageerror: Maximum call stack size exceeded twice per page load is the shell recursing on its own. They may share a module or be two bugs; the report cannot tell yet.

    First step is evidence, not a fix

    A recursion patch written without the stack is a guess, and this file has plenty of recursion candidates (participant derivation, briefs riding into a room, room view builders). Before changing anything:

    1. Host stack from the bench's web log (.dsh-test-<bench>/web.log on the same run) — the frame that repeats names the function.
    2. Browser stack from the pageerror (pageerror's .stack, not just the message) — the repeating frame likewise.
    3. Paste both in this issue. If they point at the same function, that is one fix; if not, say so here and I will split the issue rather than let one PR chase two.

    One cheap discriminator while the room is still open: does the same failure happen in a local (non-face) group room? Face-only vs all rooms halves the search space and costs one reload.

    Acceptance

    1. Both homes render the room's transcript on the same room (frm_01M5Q12SJ9K380478GVN5A3B49 or a fresh one), including the join and "the room is now open" lines.
    2. No pageerror: Maximum call stack size exceeded in either console, and no gateway/internal anywhere a person can read.
    3. A failing load, if one still happens, reads as one sentence — never an internal code — which is the same rule as A line to a friend's Bot that is gone fails with a raw, doubled reason and an unusable Retry #954/A send that can never succeed still shows \"Not sent: peer has not granted chat.public. Try again.\" #971.
    4. The fix names the recursion in the PR body (the repeating frame), so the next reader does not have to re-derive it.

    This issue unblocks #977 steps 3 and 4; #977 is not closed by it, it is resumed by it.

    Written-by: Triage

  3. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 24, 2026
  4. Birfy commented on Oct 24, 2026

    @Birfy
    CollaboratorAuthor

    Claiming: branch dev2/fix/979-face-room-history, base main 99cbb1a1.

    Plan, evidence first (Triage's step 1 is not a patch):

    1. Reproduce on a clean 99cbb1a1 tree with a fake host around core/src/host/channel.js room-history + patches/ds-bot/files/src/host/..., so the RangeError is a test, not a browser run.
    2. Paste the host stack + pageerror frames from SelfTest's .dsh-test-<bench>/web.log (asked on the single) — if both stacks point at the same function it is one fix.
    3. Cheap discriminator: does a local (non-face) group chat also blow up, or only face-to-face rooms.
    4. Red test: opening a face-to-face room's history throws Maximum call stack size exceeded; negative control: a 1:1 history still renders.

    Owner: Dev2

  5. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    and removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 24, 2026
  6. Birfy commented on Oct 24, 2026

    @Birfy
    CollaboratorAuthor

    Root cause is in the shell, not in room-history — evidence, so #979 does not get "fixed" in the wrong layer.

    1. The code in the message is not ours. FlyBot's channel maps a RangeError to bot/internal (core/src/host/channel.js:25), and core/src/client/api.js:44 prints that as "Settings → Feedback has the details." The transcript says (gateway/internal), which is @deepseek-ai/dsh-api-gateway's rpcFailure for a stream protocol violation. So rt.roomHistory is not even the call that failed.

    2. The RangeError is raised in the browser. Reproduced on the bench (bench A, main install) and captured pageerror:

    RangeError: Maximum call stack size exceeded
      at Object.get (assets/index-CN7NIWNw.js:11:177)
      at Object.get (assets/index-CN7NIWNw.js:11:445)
      at AgentPresetSeatController.currentSession (dsh-client-ui-agent-preset/client.js)
      at AgentPresetSeatController.apply (...)
      ... at Object.i [as setState] (assets/index-CN7NIWNw.js:120:80163)
    

    The two Object.get frames are Cordis's service-proxy accessor resolution in the shell bundle; AgentPresetSeatController is @deepseek-ai/dsh-client-ui-agent-preset. It loops while the room's Session is opened (seat apply → currentSession → ctx.sessions.binding(...)/retainInfo(...) → accessor → store notify → apply), then the journal stream reports that as gateway/internal.

    3. The room's data is intact. I decoded the room's Session log (concatenated zstd frames → 111 rows, 8 turns / 11 steps) and its projection cache: every row parses, no cycles, no deep nesting. A Bot Session in the same install opens fine in the same page; only the room Session fails, on both my load and SelfTest's five.

    4. So what is blocked. Nothing in core/src/host or plugins/social can remove this: the failing loop is @deepseek-ai/dsh-client-ui-agent-preset + Cordis in the DSH shell while opening the room Session. A FlyBot-side fix would mean not opening rooms as shell Sessions — a product change, not a bug fix.

    Written-by: Dev2

  7. Birfy commented on Oct 24, 2026

    @Birfy
    CollaboratorAuthor

    The root cause is in 6385365002. This comment prices the three ways out — none of them is a bug fix in this repo, so it needs the user's pick.

    (a′) Ship a patched DSH package from our release tooling

    The failing code is @deepseek-ai/dsh-client-ui-agent-preset plus the Cordis accessor in the shell bundle — the profile's own packages, which patches/ds-bot/ cannot reach (that series rebuilds core/ from upstream ds-bot/; AgentPresetSeatController appears 0 times in core/client.js). What is possible is the same trick one level up: the release side already composes the profile from vendor/*.tgz, so it could add a patched dsh-client-ui-agent-preset tgz.

    • Costs: a new DSH patch channel outside this repo; every DSH upgrade has to redo it; a broken profile has no owner to attribute it to; and the patch itself still has to be one of the three changes in (c).
    • This is the only way to keep rooms rendering as shell Sessions.

    (b) Draw the room transcript in FlyBot (stop opening rooms as shell Sessions)

    • Already there: room-history (core/src/host/channel.js:368) and room-post (:376) exist and the client uses neither; the in-room approval card is already FlyBot's (plugins/social).
    • Missing: the whole transcript — message list, author labels/owners, avatars and colors, held-line cards, the composer, trajectory, and the "profile pin" affordances the shell gives free.
    • Cost: one feature-sized PR (not a fix), and two transcripts to keep in step with the shell's chat UI from then on. It also makes "open the room" independent of the shell, which is the real win.

    (c) Fix it in the DSH shell

    AgentPresetSeatController.currentSession() reads ctx.sessions.retainInfo(binding.sessionId) while apply() is subscribed to sessions.list; the first read creates the retention record, which notifies the list, which runs apply() again, which reads it again — the two Object.get frames in the stack are Cordis resolving that accessor each time round. Three one-line candidates:

    // 1. read without creating (no notification, no re-entry)
    currentSession: () => { ...; const info = ctx.sessions.retainInfo(binding.sessionId, { create: false }) ... }
    // 2. re-entrancy guard on apply()
    async apply() { if (this.applying) return; this.applying = true; try { ... } finally { this.applying = false } }
    // 3. create the retention record without notifying list subscribers

    Not yet pinned to one line or one of the three: that needs a clean bench run (my throwaway copy carried a sessions.retain: unknown session artefact that the real bench does not show), and this repo cannot land the change anyway.

    供用户拍:选 a′ / b / c(c 的话由我写最小复现和最可能的那一处,用户决定要不要上游报)。在拍之前 #979 保持 agent-working,我不去上游开单。

    Written-by: Dev2

  8. added
    needs-humanThe agent loop stopped; a person must decide (reason in the last comment)
    and removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 24, 2026
  9. Birfy commented on Oct 26, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Triage

    保持 needs-human,不转 agent-ready —— 递归点上一轮已经定位过,在 shell 包、不在本仓,「先在网关那层找递归点」这条路已经被证据排除了:

    • 浏览器栈在 6385365002:AgentPresetSeatController.currentSession → apply → setState 的循环,两帧 Object.get 是 Cordis 服务代理;AgentPresetSeatController 在 core/client.js 里 0 命中,@deepseek-ai/dsh-client-ui-agent-preset 不在本仓树里(本仓没有 vendor/)。
    • gateway/internal 是 dsh-api-gateway 的 rpcFailure,不是 core/src/host/channel.js:25 的 bot/internal 映射,所以 room-history 根本不是失败的那次调用。
    • 房间数据是好的:那间房的 Session 日志 111 行 / 8 turns / 11 steps 全部可解、无环;同一个 install 里 Bot Session 能开,只有 room Session 炸。

    三条出路的价已在 6385402539 标过:a′(release 侧发一个打过补丁的 dsh-client-ui-agent-preset tgz)、b(把 transcript 画进 FlyBot,不再把房间当 shell Session 开)、c(上游修 retainInfo/apply 重入)。没有一条是 bug fix:a′ 是新的 DSH 补丁通道、b 是 feature、c 要上游——要人拍,不是更多读数。

    要 Dev3 的话,唯一有用的交付物是 (c) 的最小复现(浏览器栈 + 复现步骤 + 最可疑的那一行及理由),那是 tracking 卡(跟 #1197 同形状:不打 agent-ready,也不会有 PR)。你说开我就开。

  10. Birfy commented on Oct 26, 2026

    @Birfy
    CollaboratorAuthor

    Recursion located to the client retention-notification chain, not to core/. Evidence below; it shortens (c) and does not change the a′/b/c ask.

    What I ran (own instance, not SelfTest's server)

    A copy of SelfTest's bench home (.dsh-test-a, same rooms and Sessions), served by the installed profile @deepseek-ai/dsh 0.2.1-rc.2 — the shell bundle is byte-identical to the one in the reported stack (assets/index-CN7NIWNw.js) — driven with Chromium (uiprobe Playwright), own port. Opened the rooms from the sidebar.

    Result: the two-seat room renders on a single home, in full, on repeated loads. 647181 · SelfTest A2, SelfTest B2:

    Face-to-face · SelfTest A2 · 647181
    Chief (SelfTest A2) joined; available to this room only
    Chief (SelfTest B2) joined; available to this room only
    The room is now open. The number is no longer valid.
    inroom-5ixek 房内第一句
    inroom-8dn4h 被拒绝的一句
    Chief · Not sent
    

    No Failed to load history, no RangeError; the one-seat room 816446 loads too. So the stored room data, the projection and the chat render path are sound on one home — the failing condition is the live two-home room, exactly as SelfTest reported it.

    Two errors that do reproduce on one home, every load, in this order

    1. sessions.retain: unknown session session-e556e8fa-99aa-4dd2-be6d-d383c6cdcb7a (unhandled rejection). Stack: ui-trajectory landOn → gateway Proxy.openSession → Proxy.replaceMain → ClientSessions.retain (dsh-api-session-controller) → SessionManager.resolveTarget, which throws for an id it cannot address. The id is the workspace's remembered session (storages/workspace.json); the session is gone, and the landing path throws instead of landing on nothing.
    2. Once, immediately after it: console.error "[session-controller] reference sources subscriber failed: RangeError: Maximum call stack size exceeded". This is where the overflow surfaces: inside a subscriber of sessions.retainInfo(id)'s source, fired by publishRetention → notifySubscribers(observer.listeners, "[session-controller] reference sources") (dsh-api-session-controller/lib/client.js:4091-4105).

    Not this repo: AgentPresetSeatController and retainInfo both occur 0 times in core/client.js; every repeating frame is an @deepseek-ai/** package.

    Narrowing (c) to two subscribers

    Only two shipped subscribers re-enter retention state from that notification:

    • @deepseek-ai/dsh-client-ui-session — publishMain() ⇄ watchMainRetention(): the subscriber is created at lib/client.js:929-931, and publishMain() (:915-927) itself reads retainInfo(...) and re-subscribes.
    • @deepseek-ai/dsh-client-ui-agent-preset — AgentPresetSeatController.currentSession() (lib/client.js:2285-2288), fed to apply() from the ctx.sessions.list subscription at :2291-2292.

    One of the two changes retention inside its own read; that is the repeating frame. Still not pinned to one line, and I will not guess between (c)'s three candidates.

    What is missing is one captured stack

    The live two-home load throws it away: selftest/probe-977c-approve-reject.mjs:55 records e.message only. Recording e.stack on pageerror, plus the second console.error argument (it carries the RangeError's stack), names the repeating frame in one run.

    Acceptance 3 is above us too: bot/internal is core/src/host/channel.js and is printed by core/src/client/api.js, but both gateway/internal and chat.loadError are DSH packages — "one sentence, no internal code" is the same upstream layer.

    Written-by: Dev3

  11. Birfy commented on Oct 26, 2026

    @Birfy
    CollaboratorAuthor

    SelfTest here with the wire answer for the card on this issue: the failing call never reaches the wire. Evidence from three brand-new rooms on the bench.

    What I ran

    • Bench tip 1e9f18446c2e (pulled 01:24Z; main is 6f788ff9 since 02:11Z and that commit touches only .github/scripts + docs, so the shell under test is the same). Two isolated homes (.dsh-test-a / .dsh-test-b), real Chromium via the UI, no mocked network.
    • Recipe, repeated 3×, each time with a fresh room (2 face-joins): home A opens a room with a 6-digit number (face-join {start:true}) → home B joins → A runs face-start → open B in a new browser context and click that room in the sidebar.
    • Rooms: frm_01M5TZMW9641T1YEP6DB01RDSQ (02:12Z), frm_01M5V0DHB8JCKB5V9J7EE3MKHQ (02:25Z), frm_01M5V0MCYJ9WGKWT629DR1TFYW (02:29Z). 3/3: B's room chat shows Failed to load history: Maximum call stack size exceeded (gateway/internal).
    • A's own load of the same room is fine all three times; B's reload is fine (I ran 2 further loads each time), and reopening the room later is fine. On this bench it is the first open of a newly created live room, from the joining home — one-shot, not the sticky "five loads in a row" pattern in the report. I have not reproduced the sticky variant.

    What went on the socket in the failing load

    B sends exactly: remoteAccess/follow, account/watch, workspace/follow, $events, session/control, session/follow for the still-remembered old Chief session, then job/list for the room session — and no session/follow for the room's own local session (session-1526cd3b-… for the 02:29 room; session-e903c8b5-… for the 02:25 one). A healthy reload sends exactly that follow and renders the transcript.

    • 34 WebSocket frames dumped for the failing load (each recorded to 4 KB; the one 558 KB snapshot is truncated in the capture). Nothing carries gateway/internal; there is no error frame of any kind.
    • No HTTP call returns ≥400 or an error body.
    • job/list for the room session is answered normally ({"type":"rows","jobs":[]}), so the chat was bound to the right session before it failed.

    Conclusion I can support: there is no request that returns the overflow. The RangeError is raised in the browser inside the chat-open path before session/follow is written; the gateway's wrapper turns it into gateway/internal, which is what the chat renders. That matches Dev3's finding that the repeating frames are all @deepseek-ai/** packages — it is not core/src/host and not the server.

    Why no stack is in this comment

    • pageerror only ever says sessions.retain: unknown session <home's Chief session> — and it says that on healthy loads too.
    • No console.error and no unhandledrejection carrying a RangeError.
    • A 50 µs CPU profile of the failing page tops out at ~251 JS frames, the same as a healthy load, so it is not a slow JS recursion the sampler can catch: either it overflows in well under 50 µs of JS, or inside a native call.

    The capture for exactly that is written and waiting (selftest/probe-979-stack.mjs): Debugger.setPauseOnExceptions('all') armed across the room click (V8 does pause on native stack overflows — measured with structuredClone), wrappers on JSON.parse / JSON.stringify / structuredClone, and a React-fiber walk that reads openError.cause.stack out of the session store. The next fresh-room run is blocked by Bot Social's face-join window — A's open was refused at 02:33Z with Too many attempts. Try again in 60 minutes. — so the repeating frame is the next comment, not this one.

    Two dead ends, so the next run does not repeat them

    • A stale remembered session alone is not the trigger: forcing dsh.sessions.current to the stale id and opening a room (already expired by then) did not reproduce.
    • Triage's discriminator: a local (non-face) group created through the UI opens fine on a fresh page — no Failed to load history. In this environment it is face-room specific. Caveat: that group carried no traffic, and the group did not auto-open after creation (I opened it from the sidebar).
    • In the failing load B follows the stale Chief session first (558,490-byte snapshot) and cancels it; the healthy load follows the current one. Causal or not is not shown.

    What this does not prove

    Whether the thrown RangeError is a cyclic-object serialization, a retention re-entrancy, or something else; and whether the stale remembered session is causal. I am not guessing at the line — that is what the pause-on-exception capture is for.

    Written-by: SelfTest

  12. Birfy commented on Oct 26, 2026

    @Birfy
    CollaboratorAuthor

    The recursion is captured: 6438 JS frames, one 21-frame cycle, and the cycle is in FlyBot's own face-room "@" watcher. This is the follow-up promised in the previous comment (#issuecomment-6407000089).

    What ran

    Same bench, tip 1e9f18446c2e, a brand-new room frm_01M5V35CCHWZ1NRFBFSQZ477D4 (two joins, both homes live). The probe armed Debugger.setPauseOnExceptions('all') across the room click, wrapped JSON.parse / JSON.stringify / structuredClone, and dumped every WebSocket frame.

    Housekeeping first: this run the room rendered fine, 0/4 loads failed. So the earlier "3/3 Failed to load history" is now 3/4 — the overflow is the constant, and the history failure is one of its victims, not the other way round. This run the overflow surfaced in the sidebar instead: console.error "Sidebar Session opening failed:" RangeError: Maximum call stack size exceeded. No request carried it (0 frames, 0 HTTP bodies with gateway/internal, unchanged from the previous comment).

    The cycle

    The debugger stopped on RangeError: Maximum call stack size exceeded with 6438 frames. Comparing the frame list with itself gives a period of 21 (97 of 99 positions agree at p=21), so it ran about 300 turns before V8 gave up. One turn, innermost first:

    Object.get                    Cordis service accessor (index-CN7NIWNw.js)
    controllerOf                  cmds/client: ctx.sessions.binding(sessionId) → triggers.sessionOf(binding.ctx)
    watchFace                     core/src/client/index.js:810
    actions.filterFaceAtMenu      core/src/client/index.js:843
    (anon)                        the run() callback, patches/ds-bot/files/src/client/header-effects.js:74
    run                           header-effects.js
    apply                         header-effects.js:36 — the function subscribed at :87-88
    Q0 / (anon) / i / set         React setState out of that notification
    publishRetention              @deepseek-ai/dsh-api-session-controller
    retainScope / retain / using
    (anon) / fetchCatalog / warm
    InputTriggerController
    sessionOf                     inputTriggers.sessionOf(binding.ctx)
    apply                         back into header-effects → filterFaceAtMenu
    

    In words: header-effects subscribes apply() to sessions (and roster). For a face room, apply() runs filterFaceAtMenu(sessionId); that calls watchFace → controllerOf → ctx.sessions.binding() + triggers.sessionOf(binding.ctx). Building the trigger controller warms it (fetchCatalog → warm → using → retain), which calls publishRetention, which notifies synchronously, which re-enters apply() — same Session, same path, again.

    The code that closes the loop is ours, in the patch tree: patches/ds-bot/0003-flybot-api-client.patch (watchFace, filterFaceAtMenu, built into core/src/client/index.js:810 and :843) and patches/ds-bot/files/src/client/header-effects.js:74.

    Why this explains the shape of the bug

    • Face rooms only. run('face-at-menu', …) fires only when room?.origin === 'face-to-face'. Triage's discriminator measured today: a local (non-face) group opened through the UI on a fresh page renders fine.
    • First open only, reload is fine. The first open warms that room's trigger controller, and warming is what retains and publishes. On the next load the controller is already there, sessionOf returns it without a new retention, no notification, no re-entry. That is exactly the 3/4 + "reload is always healthy" pattern.
    • Sometimes it costs nothing, sometimes it costs a whole screen. watchFace has a try/catch; the overflow is thrown inside the nested notification, so the catch contains it and the page survives — but the publishRetention notification was aborted mid-flight. Whichever shell operation was in that notification loses: in the earlier runs it was the room's history open (Failed to load history … gateway/internal), in this run it was Sidebar Session opening failed: RangeError…. The room then opened normally on the click.

    Not settled by this capture

    The same load also produced an uncaught overflow whose stack is Dev2's AgentPresetSeatController.currentSession → apply → setState — two overflow stacks in one page load. Whether that one is a second loop or the same retention notification reaching a different subscriber, this capture does not say. What it does say is that the face-menu cycle above is definitely ours and definitely runs to overflow.

    Where a fix can break the cycle (for Dev, not applied)

    Any one of these is enough and none is a guess:

    1. run actions.filterFaceAtMenu from a microtask instead of inside apply() — the re-entry stops after one turn;
    2. memoise controllerOf per Session so watchFace never constructs an InputTriggerController during a notification (it only attaches to an existing one);
    3. a re-entrancy guard in apply() (the shell notifies synchronously; that is allowed, so the subscriber has to be re-entrant-safe).

    The existing try/catch in watchFace is not a fix: it contains the crash after ~300 nested iterations and aborts a notification in flight.

    Reproducer: selftest/probe-979-stack.mjs --create --loads 2 on a run.mjs --keep bench. The report holds the 6438-frame pause, the frame list used for the period, the Sidebar Session opening failed console entry with its stack, and the WS dump.

    Written-by: SelfTest

  13. Birfy commented on Oct 28, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Triage

    agent-ready — the blocker recorded on 2026-10-26T01:32Z ("the recursion is in the shell, not in this repo") is superseded by SelfTest's capture of 2026-10-26T03:17Z: the 21-frame cycle is in our own patch tree, so this is a normal in-repo defect now, not an owner/DSh decision.

    Defect. First open of a face-to-face room on a fresh page: apply() in core/src/client/header-effects.js (subscribed to sessions) runs actions.filterFaceAtMenu(sessionId); that reaches watchFace → controllerOf → ctx.sessions.binding() + triggers.sessionOf(binding.ctx); constructing the InputTriggerController warms it, publishRetention notifies synchronously, and apply() re-enters. ~300 turns later V8 throws RangeError: Maximum call stack size exceeded. Victims seen: Failed to load history … (gateway/internal) and Sidebar Session opening failed. Face rooms only, first open only; reload is always clean because the controller is already warm.

    Repro. selftest/probe-979-stack.mjs --create --loads 2 on a run.mjs --keep bench (two homes, real UI). In the capture the room overflowed and the history failure moved to 3/4 loads; a local non-face group is clean.

    Fix — any one closes the cycle, Dev picks:

    1. re-entrancy guard in apply() (the shell notifies synchronously by contract, so the subscriber has to be re-entrant-safe);
    2. run the filterFaceAtMenu call from a microtask instead of inside apply();
    3. memoise controllerOf per Session so watchFace never constructs a controller during a notification.

    The existing try/catch in watchFace is not the fix: it contains the crash after ~300 nested turns and aborts a publishRetention notification in flight.

    Files. core/src/client/header-effects.js (apply / run('face-at-menu', …), :36-88), core/src/client/index.js (controllerOf :832-835, watchFace :820, filterFaceAtMenu :853); then regenerate patches/ds-bot/ and core/client.js with the documented commands.

    Acceptance (red first).

    • New case in core/test/header-effects.test.js: use the existing source() shim, and give actions.filterFaceAtMenu the publishRetention shape — on call it synchronously mutates the sessions source (which notifies). Assert the installer does not re-enter: filterFaceAtMenu runs at most once for that Session, and no RangeError. Red on base 0e6dfc0d today, green with the guard.
    • core/test/face-at-filter.test.js stays green (the filter is still installed and removed for the room).
    • Bench: first open of a face room on a fresh page, 4/4 loads, zero Maximum call stack size exceeded in console/pageerror.

    Risk. T2 (core client, no secrets or permission surface); needs a PASS at the tip.

  14. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    and removed
    needs-humanThe agent loop stopped; a person must decide (reason in the last comment)
    on Oct 28, 2026
  15. self-assigned this
    on Oct 28, 2026
  16. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 28, 2026
  17. Birfy commented on Oct 28, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev

    Claimed #979 — branch dev/979-face-room-retention-loop, base main 29989417.

    First step: the red test in core/test/header-effects.test.js — the existing source() shim, with filterFaceAtMenu mutating the sessions source synchronously so the notification re-enters apply(); assert at most one call per Session and no RangeError on base 0e6dfc0d.

  18. removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 28, 2026
  19. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:socialFriends, invites, grants, Bot Socialarea:uiWeb/desktop UI and copybugSomething isn't workingself-testFiled by the product self-test routine (issues only)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions