{"record":{"id":"02da5bac2d16f4ec","repo":"moeru-ai/airi","slug":"stage-failed-to-post-caption-reset-for-source","errorCode":null,"errorMessage":"[Stage] Failed to post caption reset for ${source} (channel may be closed)","messagePattern":"\\[Stage\\] Failed to post caption reset for (.+?) \\(channel may be closed\\)","errorType":"console","errorClass":null,"httpStatus":null,"severity":"info","filePath":"packages/stage-ui/src/components/scenes/Stage.vue","lineNumber":191,"sourceCode":"  }, 100)\n}))\n\nconst audioAnalyser = ref<AnalyserNode>()\nconst lipSyncStarted = ref(false)\nconst lipSyncLoopId = ref<number>()\nconst live2dLipSync = ref<Live2DLipSync>()\nconst live2dLipSyncOptions: Live2DLipSyncOptions = { mouthUpdateIntervalMs: 50, mouthLerpWindowMs: 50 }\n\nfunction resetAssistantSpeechSurface(source: string) {\n  nowSpeaking.value = false\n  mouthOpenSize.value = 0\n  assistantCaption.value = ''\n\n  try {\n    postCaption({ type: 'caption-assistant', text: '' })\n  }\n  catch (error) {\n    console.warn(`[Stage] Failed to post caption reset for ${source} (channel may be closed)`, { error })\n  }\n\n  try {\n    postPresent({ type: 'assistant-reset' })\n  }\n  catch (error) {\n    console.warn(`[Stage] Failed to post present reset for ${source} (channel may be closed)`, { error })\n  }\n}\n\nconst { activeCard } = storeToRefs(useAiriCardStore())\nconst speechStore = useSpeechStore()\nconst { ssmlEnabled, activeSpeechProvider, activeSpeechModel, activeSpeechVoice, pitch } = storeToRefs(speechStore)\nconst activeCardId = computed(() => activeCard.value?.name ?? 'default')\nconst speechRuntimeStore = useSpeechRuntimeStore()\nconst { trackOfficialTtsAutoEnabled } = useAnalytics()\nlet officialAutoTtsTrackedForTurn = false\nconst backgroundStore = useBackgroundStore()","sourceCodeStart":173,"sourceCodeEnd":209,"githubUrl":"https://github.com/moeru-ai/airi/blob/677329427f32468c74b17f3ec47eeca4e05bec65/packages/stage-ui/src/components/scenes/Stage.vue#L173-L209","documentation":"Stage.vue's resetAssistantSpeechSurface resets speech state (nowSpeaking, mouthOpenSize, assistantCaption) and then posts an empty 'caption-assistant' event over the stage channel (postCaption). If the underlying channel is already closed when the post runs, it throws synchronously and this warning is logged. The local state reset happens first, so the UI is still cleared; only the cross-surface caption broadcast was lost.","triggerScenarios":"Calling resetAssistantSpeechSurface (speech end, abort, or teardown paths) after the caption channel closed: component unmount, window teardown, hot reload, or a second reset racing the channel's close.","commonSituations":"Closing the desktop window mid-utterance; Vue HMR re-running setup; duplicated cleanup invocations (error handler plus onUnmounted both firing).","solutions":["Treat as benign when it only appears during teardown or reload — state reset already succeeded","Stop speech and post the caption reset before closing/disposing the stage channel (order teardown: speech stop -> caption reset -> channel close)","Track channel closed state and skip posting when already closed to silence the warning"],"exampleFix":"// before\ntry {\n  postCaption({ type: 'caption-assistant', text: '' })\n}\ncatch (error) {\n  console.warn(`[Stage] Failed to post caption reset for ${source} (channel may be closed)`, { error })\n}\n\n// after\nif (!captionChannelClosed.value) {\n  try {\n    postCaption({ type: 'caption-assistant', text: '' })\n  }\n  catch (error) {\n    captionChannelClosed.value = true\n    console.warn(`[Stage] Failed to post caption reset for ${source} (channel may be closed)`, { error })\n  }\n}","handlingStrategy":"validation","validationCode":"// track channel liveness once\nlet captionChannelClosed = false\ncaptionChannel.onclose = () => { captionChannelClosed = true }\n\n// before posting\nif (!captionChannelClosed) {\n  postCaption({ type: 'caption-assistant', text: '' })\n}","typeGuard":null,"tryCatchPattern":"try {\n  postCaption({ type: 'caption-assistant', text: '' })\n}\ncatch (error) {\n  // channel closed during teardown: state already reset, safe to ignore\n  if (!isTeardown)\n    console.warn('[Stage] caption reset failed', { error })\n}","preventionTips":["Order teardown: stop speech -> post resets -> close channels","Post caption resets from the channel owner so posts cannot outlive the channel","Treat this exact message during unload/reload as noise, not a defect"],"tags":["broadcast-channel","teardown","speech","stage"],"backgroundTag":"channel-closed-on-write","analyzedSha":"677329427f32468c74b17f3ec47eeca4e05bec65","analyzedAt":"2026-08-18T17:29:58.153Z","contentChangedAt":"2026-08-18T17:29:58.153Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}