diff --git a/README.md b/README.md
index d68bc51..dd647c4 100644
--- a/README.md
+++ b/README.md
@@ -45,8 +45,8 @@ from a phone.
2. **Open Match Setup.** Double-tap the touch bar on Stack-chan's head, or
briefly press Power, to show the setup QR. Scan it with your phone.
3. **Choose what to watch.** Select a match, supported team (or Neutral),
- optional pregame position (or No position), and commentary style, then tap
- **Start watching**.
+ optional pregame position (or No position), commentary style, and whether
+ to use spoiler protection, then tap **Start watching**.
4. **Wait for confirmation.** The watcher validates the ESPN/Kalshi pairing,
updates its configuration, and acknowledges the device. The new selection
takes effect without restarting the watcher or device. Changing only the
@@ -91,6 +91,8 @@ not the primary QR flow.
facts, with compact on-device balloons.
- Separate support and position perspectives, including explicit conflict and
uncertain-event wording.
+- Optional spoiler protection suppresses proactive Kalshi alerts while
+ confirmed ESPN events and passive probability/ticker updates continue.
- Phone-based match setup with live Chinese/English switching and hot reload.
- Automatic fixture discovery, adaptive polling, quiet hours, and standalone
market tracking when no match is available.
@@ -102,17 +104,21 @@ not the primary QR flow.
### Daily start
-From the repository root of an existing, configured checkout, keep the watcher
-computer awake and run:
+Update the repository and restart the watcher. Support/position wording,
+position-based market selection, and the global player catalog remain
+watcher-only changes. The phone spoiler-protection switch requires Matchday
+Mod 1.6.0, but the official host can stay in place.
```sh
python3 tools/stackchan_kalshi_watch.py \
--config config/kalshi_watchlist.json --watch
```
-Daily startup does not require cloning, pulling, or reflashing. If speech is
-enabled, also start the optional LAN TTS service described in
-[Getting started](docs/getting-started.md).
+Deploy `config/espn_player_catalog.json` with the watcher checkout. For exact
+upgrade boundaries, see the bilingual
+[Matchday MOD 1.6.0 notes](docs/releases/1.6.0.md),
+[1.5.0 notes](docs/releases/1.5.0.md), and
+[1.4.0 commentary-style notes](docs/releases/1.4.0.md).
### First installation
@@ -145,8 +151,8 @@ The currently documented device release is [Matchday Mod 1.5.0](docs/releases/1.
| [Development](docs/development.md) | Repository map, tests, archive builds, and replay tooling |
| [Host preparation](host/README.md) | Partition and optional CJK-font patches |
| [Commentary styles PRD](docs/commentary-styles-prd.md) | Product rules and implementation contract |
-| [Release notes](docs/releases/) | Version-specific changes and upgrade boundaries |
-| [Troubleshooting Wiki](https://github.com/xymeow/stackchan-matchday/wiki/Troubleshooting) | FAQ, networking checks, and field debugging |
+| [Release notes](docs/releases/1.6.0.md) | Version-specific upgrade boundaries |
+| [GitHub Wiki](https://github.com/xymeow/stackchan-matchday/wiki) | Human- and agent-friendly navigation, FAQ, and field debugging |
English guides link to their Chinese counterparts at the top of each page.
Repository documentation is the versioned source of truth; the Wiki is the
@@ -164,6 +170,9 @@ short operational index and troubleshooting layer.
- Commentary style can change during a match without resetting ESPN history,
market baselines, queues, or polling state. API compatibility is documented
in the [Device API guide](docs/device-api.md).
+- Spoiler protection can also change live. It silences proactive Kalshi
+ alerts, not the passive probability bar or ticker, and never suppresses
+ confirmed ESPN events.
### For maintainers and AI agents
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 4bd0f11..40e5edf 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -49,8 +49,8 @@ watcher,可把 CoreS3 机器人变成世界杯陪看搭子:屏幕持续显
电脑和 Stack-chan 必须位于同一个可信局域网。
2. **唤出设置码。** 双击头顶触摸条或短按 Power 键,屏幕会显示设置二维码;轻点
一次可关闭,90 秒后也会自动关闭。
-3. **用手机选择。** 扫码后选择语言、比赛、支持队、可选持仓和播报语气,再点
- “开始看球”。页面显示 watcher 已确认后,配置立即生效,无需重启。
+3. **用手机选择。** 扫码后选择语言、比赛、支持队、可选持仓、播报语气和是否开启
+ 防剧透,再点“开始看球”。页面显示 watcher 已确认后,配置立即生效,无需重启。
4. **一起看球。** Stack-chan 会持续更新旗帜、概率和 ticker,并响应比分、文字直播
与盘口变化。
@@ -100,6 +100,8 @@ Kalshi + ESPN ──只读──> Python watcher ──HTTP──> Stack-chan Ma
- 对进球、红黄牌、换人、险情、比赛阶段和赛果做出反应。
- `casual`、`balanced`、`professional` 三档播报语气,可在比赛中即时切换。
- 支持队和持仓队分开建模;重大事件会自然说明持仓利好或利空。
+- 可选防剧透模式:关闭 Kalshi 主动提醒,同时保留 ESPN 已确认事件与静默更新的
+ 概率条和 ticker。
- 由设备托管的中英文手机设置页,以及 watcher 的配置热更新与确认链路。
- 自动发现比赛、自适应轮询、免打扰时段和无比赛市场跟踪。
- 可选局域网 TTS;服务不可用时,视觉反馈和提示音仍然工作。
@@ -116,6 +118,10 @@ python3 tools/stackchan_kalshi_watch.py \
--config config/kalshi_watchlist.json --watch
```
+示例 ticker 是占位值;从手机设置页选择真实比赛后,watcher 会校验 ESPN 与 Kalshi
+的双方匹配并原子更新配置。旧版升级前请查看
+[Matchday Mod 1.6.0 说明](docs/releases/1.6.0.md)及[全部版本说明](docs/releases/);
+设备手机页的防剧透开关需要更新 watcher 与 Mod,但无需重刷官方 host。
日常启动不需要 `git pull`、重新克隆或刷机。需要语音时,再启动
[安装与升级](docs/getting-started.zh-CN.md)中说明的可选局域网 TTS 服务。
@@ -162,6 +168,8 @@ watcher。完整、可复现的命令见[安装与升级](docs/getting-started.z
- ESPN 接口并非正式 API;缺失或含糊的上游细节会被忽略,不会猜测足球事实。
- 比赛中切换语气不会重置 ESPN 历史、盘口基线、队列或轮询状态;API 兼容性见
[设备 API](docs/device-api.zh-CN.md)。
+- 防剧透也可在比赛中即时切换:只关闭 Kalshi 主动提醒,不隐藏概率条或 ticker,
+ 也不会屏蔽 ESPN 已确认事件。
### 给维护者和 AI agent
diff --git a/config/kalshi_watchlist.example.json b/config/kalshi_watchlist.example.json
index 316919a..9273208 100644
--- a/config/kalshi_watchlist.example.json
+++ b/config/kalshi_watchlist.example.json
@@ -4,6 +4,7 @@
"stackchan_serial_port": "/dev/cu.usbmodem101",
"stackchan_serial_baud": 115200,
"language": "zh",
+ "spoiler_free_mode": false,
"voice_transport": "clip",
"mac_voice": {"zh": "Tingting", "en": "Samantha"},
"mac_say_rate": 185,
diff --git a/docs/configuration.md b/docs/configuration.md
index 6f8bf4b..34c907a 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -4,9 +4,10 @@
The phone setup page is the normal control surface. It changes all labels
immediately, persists its language on the device, and applies the selected
-match, supported team, optional position, and commentary style through the
-watcher's pending/ack flow. The watcher validates the ESPN/Kalshi pairing,
-atomically updates its local JSON, hot-reloads, and acknowledges Stack-chan.
+match, supported team, optional position, commentary style, and spoiler
+protection through the watcher's pending/ack flow. The watcher validates the
+ESPN/Kalshi pairing, atomically updates its local JSON, hot-reloads, and
+acknowledges Stack-chan.
## Match Setup flow
@@ -48,6 +49,7 @@ legacy string or a localized object:
```json
{
"language": "en",
+ "spoiler_free_mode": false,
"mac_voice": {"zh": "Tingting", "en": "Samantha"},
"espn": {
"commentary_style": "balanced",
@@ -122,6 +124,22 @@ effects, celebrations, expressions, lights, priority, and alert switches are
unchanged. Switching during a match affects newly generated alerts without
replaying old ESPN events or resetting market baselines, queues, or polling.
+## Spoiler protection
+
+Top-level `spoiler_free_mode` is a persistent global boolean and defaults to
+`false`. Both phone setup pages expose Normal and Spoiler protection choices.
+
+When enabled, the watcher still polls Kalshi and silently updates market
+baselines, the probability bar, and ticker, but suppresses all proactive
+Kalshi-derived alerts, including price/spread or market-status changes,
+closing notices, and suspected-goal signals. Confirmed ESPN events, phases,
+and results continue normally.
+
+The dedicated hot-update path applies during a match without resetting ESPN
+history, market state, polling, or the selected fixture. Enabling protection
+also drops queued market alerts and consumes their current baselines, so
+turning it off later does not replay accumulated movement.
+
## Support and position perspectives
The supported team defines everyday co-watching language. Routine shots,
diff --git a/docs/configuration.zh-CN.md b/docs/configuration.zh-CN.md
index 795850c..1861afd 100644
--- a/docs/configuration.zh-CN.md
+++ b/docs/configuration.zh-CN.md
@@ -2,8 +2,8 @@
[English](configuration.md) | [简体中文](configuration.zh-CN.md)
-本文说明 watcher 的持久配置、手机 Match Setup、语言与三档播报、支持/持仓视角、
-球员目录和独立市场模式。配置示例位于
+本文说明 watcher 的持久配置、手机 Match Setup、语言与三档播报、防剧透模式、
+支持/持仓视角、球员目录和独立市场模式。配置示例位于
`config/kalshi_watchlist.example.json`;请把本机修改写入未提交的
`config/kalshi_watchlist.json`。
@@ -33,6 +33,7 @@ IPv4 地址或主机名,不支持 IPv6 字面地址。
"stackchan_host": "stackchan.local",
"stackchan_transport": "http",
"language": "zh",
+ "spoiler_free_mode": false,
"setup_server": {
"enabled": true,
"host": "127.0.0.1",
@@ -108,6 +109,19 @@ python3 tools/stackchan_kalshi_watch.py discover --query 关键词
语气只改变文案,不改变 TTS 音色和语速、音效、动作、表情、灯效、优先级或提醒
开关。比赛中切换立即作用于新提醒,但不会重播旧事件。
+## 防剧透模式
+
+顶层 `spoiler_free_mode` 是全局持久布尔值,旧配置缺少时默认 `false`。设备和
+watcher 的手机设置页都提供“普通 / 防剧透”选项。
+
+开启后 watcher 仍持续轮询 Kalshi,并静默更新盘口基线、概率条和 ticker,但不会
+主动播报任何 Kalshi 派生提醒,包括价格/价差、市场状态变化、临近收盘和疑似进球。
+ESPN 已确认的事件、比赛阶段与赛果照常播报。
+
+独立热更新链路可在比赛中即时切换,不会重置 ESPN 已读历史、盘口状态、轮询或当前
+比赛。开启时还会丢弃队列中尚未播出的盘口提醒并消费当前基线,之后关闭不会补播
+累积变化。
+
## 支持队与持仓队
- `espn.favorite_team` 是支持队,决定普通射门、扑救、角球等日常陪看视角;可中立。
diff --git a/docs/development.md b/docs/development.md
index 1bd92ca..74604db 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -30,6 +30,12 @@ The Python suite covers watcher behavior, Match Setup, commentary contracts,
and device-facing integration helpers. The Node suite exercises the mod web
surface without installing it on hardware.
+Spoiler-protection changes should prove that Kalshi-derived alerts are
+suppressed while market state and passive displays keep updating, confirmed
+ESPN alerts remain eligible, queued market alerts are purged on hot enable,
+and a later disable does not replay accumulated movement. Cover watcher-local
+and device pending/ack paths, including the boolean `false` case.
+
## Build without installing
Build a mod archive from the upstream Stack-chan `firmware/` directory:
diff --git a/docs/development.zh-CN.md b/docs/development.zh-CN.md
index ca6a11d..cd37fc3 100644
--- a/docs/development.zh-CN.md
+++ b/docs/development.zh-CN.md
@@ -36,6 +36,9 @@ Python 测试覆盖 watcher、Match Setup、结构化事实、文案和投递行
- 疑似进球与等待确认在所有语气中保持不确定性。
- 专业细节只来自可可靠解析的 ESPN 字段;缺失值不能触发猜测。
- 只切换语气不能重复旧事件或重置盘口、告警与轮询状态。
+- 防剧透开启后应抑制 Kalshi 派生提醒,但继续更新盘口状态与常驻显示;ESPN 已确认
+ 事件仍可投递。热开启需要清空待播盘口提醒,之后关闭不能补播累积变化。
+- watcher 本机与设备 pending/ack 两条链路都要覆盖,尤其要测试布尔值 `false`。
- Match Setup 需要覆盖设备 pending/ack 与 watcher 原子更新两端。
- 投递失败测试应验证退避和有限重试,避免设备重启风暴。
diff --git a/docs/device-api.md b/docs/device-api.md
index de0e702..e6a7f39 100644
--- a/docs/device-api.md
+++ b/docs/device-api.md
@@ -72,6 +72,8 @@ The device-facing setup surface uses these endpoints under `/api/match-setup`:
- `/pending` lets the watcher retrieve that selection.
- `/ack` confirms success or reports validation failure back to the phone.
- `/language` updates the persisted device language.
+- `/style` queues a commentary-style-only update.
+- `/spoiler` queues a spoiler-protection-only update.
These endpoints form a pending/ack handshake: the phone writes to Stack-chan,
the watcher validates and atomically updates its local configuration, and the
@@ -94,6 +96,23 @@ Changing only the style must not reset ESPN event history, market baselines,
alert queues, or polling state, and must not replay old commentary. The new
style applies to subsequently generated alerts.
+## Spoiler-protection endpoints
+
+The device accepts a strict JSON boolean at `POST /api/match-setup/spoiler`:
+
+```json
+{"spoiler_free_mode":true}
+```
+
+It relays the preference through pending/ack. The watcher-local service accepts
+the same body at `POST /api/setup/spoiler`, and `GET /api/setup/status` reports
+the effective `spoiler_free_mode` value.
+
+Changing only this preference must not reload the selected match or reset ESPN
+history, market baselines, or polling. Enabling it drops queued Kalshi alerts;
+confirmed ESPN events continue, while the probability bar and ticker still
+update silently.
+
## Calling conventions
- Use every endpoint only on a trusted LAN; the device API has no
diff --git a/docs/device-api.zh-CN.md b/docs/device-api.zh-CN.md
index 2275a59..e370bb4 100644
--- a/docs/device-api.zh-CN.md
+++ b/docs/device-api.zh-CN.md
@@ -84,6 +84,7 @@ curl --request POST \
| `/api/match-setup/ack` | watcher 确认或拒绝应用结果 |
| `/api/match-setup/language` | 更新界面语言 |
| `/api/match-setup/style` | 更新播报语气 |
+| `/api/match-setup/spoiler` | 更新防剧透模式 |
这是一个异步确认流程:设备收到手机选择不代表 watcher 已采用。客户端应显示待处理
状态,直到 watcher 返回 ack;不要在提交后自行假定配置成功。
@@ -111,6 +112,30 @@ Content-Type: application/json
`GET /api/setup/status` 返回 watcher 当前生效值。切换只影响新生成文案,不应重置
ESPN 已读事件、盘口基线、告警队列或轮询状态。
+### 即时切换防剧透模式
+
+设备接收严格的 JSON 布尔值:
+
+```http
+POST /api/match-setup/spoiler
+Content-Type: application/json
+
+{"spoiler_free_mode":true}
+```
+
+它通过 pending/ack 流程转发。watcher 本机设置服务的对应接口为:
+
+```http
+POST /api/setup/spoiler
+Content-Type: application/json
+
+{"spoiler_free_mode":true}
+```
+
+`GET /api/setup/status` 返回当前生效的 `spoiler_free_mode`。只切换该偏好不会重载
+比赛或重置 ESPN 历史、盘口基线与轮询;开启后丢弃队列中的 Kalshi 提醒,已确认的
+ESPN 事件照常播报,概率条和 ticker 继续静默更新。
+
## 调用约定
- 所有接口仅用于可信局域网;设备 API 无认证并开放 CORS。
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 5a6b1f5..719fcc8 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -8,11 +8,11 @@ building the Matchday mod, starting the watcher, and optionally enabling LAN
speech.
Already running Matchday? Read the version-specific
-[1.5.0](releases/1.5.0.md) and [1.4.0](releases/1.4.0.md) notes before rebuilding.
-Support/position-aware wording, position-based market selection, and the global
-player catalog are watcher-only updates. An installed 1.5.0 mod can stay in
-place: deploy the watcher code and `config/espn_player_catalog.json`, then
-restart the watcher process.
+[1.6.0](releases/1.6.0.md), [1.5.0](releases/1.5.0.md), and
+[1.4.0](releases/1.4.0.md) notes before rebuilding. Spoiler protection on the
+device phone page requires both the 1.6.0 watcher and mod, but not a host
+reflash. The 1.5.0 support/position wording and global player catalog remain
+watcher-only updates.
## Requirements
diff --git a/docs/getting-started.zh-CN.md b/docs/getting-started.zh-CN.md
index b538315..f94e498 100644
--- a/docs/getting-started.zh-CN.md
+++ b/docs/getting-started.zh-CN.md
@@ -186,6 +186,7 @@ curl --request POST --data-binary "say 比赛日准备好了" \
| 变更 | 通常需要更新 |
| --- | --- |
| watcher 文案、球员目录、解析和轮询 | watcher 代码与配置;重启 Python 进程 |
+| 防剧透手机开关 | watcher 与 Matchday Mod 1.6.0;无需重刷 host |
| Mod 页面、设备动作、命令或资源 | 重新构建并安装 Mod |
| `xs` 分区或 CJK host 字体 | 重新应用补丁并烧录 host |
| macOS TTS 服务实现 | 重启 TTS 服务;不需要刷设备 |
diff --git a/docs/releases/1.5.0.md b/docs/releases/1.5.0.md
index 4673c40..0d8deb6 100644
--- a/docs/releases/1.5.0.md
+++ b/docs/releases/1.5.0.md
@@ -56,6 +56,14 @@ A formal-name source is not treated as permission to invent a nickname.
### Reliability
+- Goal and final-result feedback is now explicitly two-stage in the watcher:
+ the local fanfare and head motion finish before dynamic LAN TTS starts. The
+ TTS request remains blocking, so the watcher no longer opens four status
+ connections per second while the WAV stream is active. A TTS-only failure
+ does not replay an already delivered goal celebration.
+- Suspected-goal market alerts restore the punchier "market jumped / possible
+ goal / don't blink" cadence while still naming the inferred team and
+ explicitly waiting for commentary confirmation.
- Tone clips are skipped (successfully) while a TTS stream is playing instead
of erroring into the watcher's retry path — one of the two halves of the
mid-match retry-storm fix.
@@ -109,6 +117,11 @@ A formal-name source is not treated as permission to invent a nickname.
### 可靠性
+- 进球和赛果反馈改为 watcher 两段式执行:先完成本地音效与头部动作,再启动局域网
+ 动态 TTS。TTS 请求保持阻塞,WAV 播放期间不再每秒发出 4 次状态查询;如果仅语音
+ 失败,也不会把已经播过的进球庆祝重新来一遍。
+- 盘口疑似进球提醒恢复“盘口突变 / 很可能进球 / 先别眨眼”的紧张感,同时继续
+ 明确球队,并保留等待文字直播确认的不确定性。
- TTS 播放中的音效改为成功跳过而不是报错,不再触发 watcher 的失败重试——
这是比赛中"重试风暴"修复的设备侧一半。
- watcher 失败投递改为退避 10 秒后重试,普通实况最多重试 2 次(进球/点球/红牌
diff --git a/docs/releases/1.6.0.md b/docs/releases/1.6.0.md
new file mode 100644
index 0000000..2f8f817
--- /dev/null
+++ b/docs/releases/1.6.0.md
@@ -0,0 +1,109 @@
+# Matchday MOD 1.6.0
+
+[English](#english) | [简体中文](#简体中文)
+
+## English
+
+### Spoiler protection
+
+- Adds a persistent global `spoiler_free_mode` preference, defaulting to
+ `false` for existing configurations.
+- When enabled, the watcher still polls Kalshi and updates market baselines,
+ the probability bar, and ticker, but suppresses every proactive
+ Kalshi-derived alert—including price/spread or market-status changes,
+ closing notices, and suspected-goal signals.
+- Confirmed ESPN events, match phases, and results continue normally. The mode
+ does not turn delayed market signals into confirmed football events.
+- Turning protection on during a match immediately drops queued market alerts
+ and consumes their current baselines, so turning it off later does not replay
+ or catch up old market movement.
+
+### Phone setup and API
+
+- Both the device `/setup` page and the watcher-local setup page provide
+ Normal and Spoiler protection choices with the currently effective value.
+- The device sends `POST /api/match-setup/spoiler`; the watcher-local service
+ accepts `POST /api/setup/spoiler`. Both use
+ `{"spoiler_free_mode":true|false}`.
+- The dedicated hot-update path persists globally without resetting ESPN
+ history, market state, polling, or the selected match.
+- Full fixture and standalone-market setup also carry the preference. Legacy
+ clients that omit it preserve the existing effective value.
+
+### Reliability fixes
+
+- The device HTTP service no longer handles requests inside the accept loop.
+ Previously one request whose body stalled (a phone leaving Wi-Fi mid-POST)
+ froze the entire server until reboot, and every spoken alert made the server
+ deaf for the whole playback, piling up watcher retries. Requests are now
+ served concurrently with a 45s watchdog per connection, a concurrency cap,
+ and automatic listener restart. Reproduced and verified on device: a stalled
+ POST no longer blocks other clients, and `/api/status` stays responsive
+ through a 36-second speech.
+- The HTTP listener is now vendored into the mod (`matchday/listen-safe`) with
+ both connection promises observed at creation. Upstream leaves them
+ unobserved until `respondWith`, and an early peer reset then aborts the
+ release build — the source of silent `unhandled rejection` device restarts.
+- `lastAbort` is consumed at boot, so `/api/status` only ever reports an abort
+ of the run immediately before the current boot.
+- Remote TTS is vendored (`matchday/tts-remote-safe`) with a 1500ms stream
+ buffer and a 16-slot audio queue (the host pins 600ms, and the default
+ 8-slot queue halves whatever is configured). This removes the audible
+ mid-sentence dropouts on long speeches; hosts without the audio modules fall
+ back to the host `tts-remote` automatically.
+
+### Upgrade boundary
+
+- Update the watcher and install Matchday Mod 1.6.0 to expose the toggle on the
+ device phone page and keep its effective state synchronized.
+- The official host firmware, partition layout, optional CJK font, and LAN TTS
+ service are unchanged and do not need to be rebuilt.
+- The passive probability bar and ticker remain visible in this first version.
+ Fully hiding or freezing market visuals is a separate possible mode.
+
+## 简体中文
+
+### 防剧透模式
+
+- 新增全局持久偏好 `spoiler_free_mode`;旧配置缺少该字段时默认 `false`,保持原有
+ 行为。
+- 开启后 watcher 仍持续轮询 Kalshi,并更新盘口基线、概率条和 ticker,但不再主动
+ 播报任何 Kalshi 派生提醒,包括价格/价差或市场状态变化、临近收盘和疑似进球。
+- ESPN 已确认的比赛事件、阶段与赛果照常播报;防剧透模式不会把延迟的盘口信号当成
+ 已确认足球事实。
+- 比赛中开启后会立即丢弃队列里尚未播出的盘口提醒,并消费当前基线;之后关闭时不会
+ 补播或追赶此前累积的盘口变化。
+
+### 手机设置与接口
+
+- 设备 `/setup` 页面和 watcher 本机设置页都新增“普通 / 防剧透”选项,并显示当前
+ 生效值。
+- 设备使用 `POST /api/match-setup/spoiler`,watcher 本机服务使用
+ `POST /api/setup/spoiler`;请求体均为 `{"spoiler_free_mode":true|false}`。
+- 独立热更新链路会持久保存全局偏好,但不会重置 ESPN 已读历史、盘口状态、轮询或
+ 当前比赛。
+- 完整比赛设置和独立市场模式也会携带该偏好;旧客户端省略字段时保留当前生效值。
+
+### 可靠性修复
+
+- 设备 HTTP 服务不再在 accept 循环内处理请求。此前只要有一个请求的 body 断流
+ (手机发送中途离开 Wi-Fi),整个服务就永久卡死直到重启;且每次语音播报期间
+ 服务全程失聪,watcher 重试成堆。现在每个连接独立处理,配 45 秒看门狗、并发
+ 上限和 listener 自动重启。真机复现并验证:断流 POST 不再阻塞其他请求,36 秒
+ 长播报期间 `/api/status` 全程可响应。
+- HTTP listener 内置进 mod(`matchday/listen-safe`),两个连接 promise 创建即被
+ 观察。上游版本在 `respondWith` 之前无人观察,对端过早断开会让 release 构建
+ 直接 abort——这正是"unhandled rejection"静默重启的来源。
+- `lastAbort` 启动时读取后即清除,`/api/status` 只报告紧邻本次启动的上一轮
+ abort,不再残留旧账。
+- 远程 TTS 内置进 mod(`matchday/tts-remote-safe`),流缓冲提高到 1500ms、音频
+ 队列 16 槽(host 固定 600ms,且默认 8 槽队列会把实际深度再腰斩)。长句中间
+ 的可听断音由此消除;host 缺音频模块时自动回落到原 `tts-remote`。
+
+### 升级边界
+
+- 需要同时更新 watcher 并安装 Matchday Mod 1.6.0,设备手机页才会显示开关并同步
+ 当前状态。
+- 官方 host firmware、分区表、可选 CJK 字体和局域网 TTS 均未改变,无需重新构建。
+- 第一版仍保留静默更新的概率条与 ticker;完全隐藏或冻结盘口视觉信息可作为另一种
+ 后续模式。
diff --git a/host/sdk-patches/0001-esp32-tcp-clear-callbacks-safe.patch b/host/sdk-patches/0001-esp32-tcp-clear-callbacks-safe.patch
new file mode 100644
index 0000000..2e45d6e
--- /dev/null
+++ b/host/sdk-patches/0001-esp32-tcp-clear-callbacks-safe.patch
@@ -0,0 +1,125 @@
+diff --git a/modules/io/socket/lwip/tcp.c b/modules/io/socket/lwip/tcp.c
+index 0f90023..63a2820 100644
+--- a/modules/io/socket/lwip/tcp.c
++++ b/modules/io/socket/lwip/tcp.c
+@@ -262,6 +262,11 @@ void xs_tcp_destructor(void *data)
+ void removeTCPCallbacks(TCP tcp)
+ {
+ if (tcp->skt) {
++ // Testing the maintainer-suggested variant (moddable#1655): clear the
++ // callback argument first so any late callback observes NULL (the
++ // guards in tcpReceive/tcpSent/tcpError), then clear the callbacks
++ // with plain pointer stores — no tcpip-thread marshaling.
++ tcp_arg(tcp->skt, NULL);
+ tcp_recv(tcp->skt, NULL);
+ tcp_sent(tcp->skt, NULL);
+ tcp_err(tcp->skt, NULL);
+@@ -358,7 +363,11 @@ void xs_tcp_read(xsMachine *the)
+ buffer->fragment = fragment->next;
+ buffer->fragmentOffset = 0;
+ if (NULL == buffer->fragment) {
++ // Detach under the same guard tcpReceive uses to append, so
++ // its tail walk never lands on a node about to be freed.
++ builtinCriticalSectionBegin();
+ tcp->buffers = buffer->next;
++ builtinCriticalSectionEnd();
+ tcp_recved_safe(tcp->skt, buffer->pb->tot_len);
+ pbuf_free_safe(buffer->pb);
+ c_free(buffer);
+@@ -535,6 +544,9 @@ void tcpError(void *arg, err_t err)
+ {
+ TCP tcp = arg;
+
++ if (NULL == tcp) // callbacks cleared during teardown
++ return;
++
+ removeTCPCallbacks(tcp);
+ tcp->skt = NULL; // "pcb is already freed when this callback is called"
+ if (kTCPError & tcp->triggerable)
+@@ -546,6 +558,12 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err)
+ TCP tcp = arg;
+ TCPBuffer buffer;
+
++ if (NULL == tcp) { // callbacks cleared during teardown
++ if (pb)
++ pbuf_free(pb);
++ return ERR_OK;
++ }
++
+ if ((NULL == pb) || (ERR_OK != err)) { //@@ when is err set here?
+ removeTCPCallbacks(tcp);
+ #if ESP32
+@@ -572,6 +590,10 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err)
+
+ modInstrumentationAdjust(NetworkBytesRead, buffer->bytes);
+
++ // The XS task pops and frees head nodes in xs_tcp_read concurrently with
++ // this append; guard the pointer surgery so the tail walk cannot traverse
++ // a node being detached/freed.
++ builtinCriticalSectionBegin();
+ if (tcp->buffers) {
+ TCPBuffer walker;
+
+@@ -581,6 +603,7 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err)
+ }
+ else
+ tcp->buffers = buffer;
++ builtinCriticalSectionEnd();
+
+ if (tcp->triggerable & kTCPReadable)
+ tcpTrigger(tcp, kTCPReadable);
+@@ -590,6 +613,9 @@ err_t tcpReceive(void *arg, struct tcp_pcb *pcb, struct pbuf *pb, err_t err)
+
+ err_t tcpSent(void *arg, struct tcp_pcb *pcb, u16_t len)
+ {
++ if (NULL == arg) // callbacks cleared during teardown
++ return ERR_OK;
++
+ tcpTrigger((TCP)arg, kTCPWritable);
+ return ERR_OK;
+ }
+diff --git a/modules/network/socket/lwip/modLwipSafe.c b/modules/network/socket/lwip/modLwipSafe.c
+index a4cc7fb..dc699dd 100644
+--- a/modules/network/socket/lwip/modLwipSafe.c
++++ b/modules/network/socket/lwip/modLwipSafe.c
+@@ -169,6 +169,28 @@ void tcp_recved_safe(struct tcp_pcb *tcpPCB, u16_t len)
+ tcpip_callback_with_block(tcp_recved_INLWIP, msg, 1);
+ }
+
++static err_t tcp_clear_callbacks_INLWIP(struct tcpip_api_call_data *tcpMsg)
++{
++ LwipMsg msg = (LwipMsg)tcpMsg;
++ if (msg->tcpPCB) {
++ tcp_arg(msg->tcpPCB, NULL);
++ tcp_recv(msg->tcpPCB, NULL);
++ tcp_sent(msg->tcpPCB, NULL);
++ tcp_err(msg->tcpPCB, NULL);
++ }
++ return ERR_OK;
++}
++
++// Synchronous: when this returns, no lwIP callback for this pcb is running
++// or can run again, so the caller may safely free its callback argument.
++void tcp_clear_callbacks_safe(struct tcp_pcb *tcpPCB)
++{
++ LwipMsgRecord msg = {
++ .tcpPCB = tcpPCB,
++ };
++ tcpip_api_call(tcp_clear_callbacks_INLWIP, &msg.call);
++}
++
+ static err_t tcp_listen_INLWIP(struct tcpip_api_call_data *tcpMsg)
+ {
+ LwipMsg msg = (LwipMsg)tcpMsg;
+diff --git a/modules/network/socket/lwip/modLwipSafe.h b/modules/network/socket/lwip/modLwipSafe.h
+index 717808d..0f5c94f 100644
+--- a/modules/network/socket/lwip/modLwipSafe.h
++++ b/modules/network/socket/lwip/modLwipSafe.h
+@@ -52,6 +52,7 @@
+ #include "lwip/raw.h"
+
+ struct tcp_pcb *tcp_new_safe(void);
++ void tcp_clear_callbacks_safe(struct tcp_pcb *tcpPCB);
+ err_t tcp_connect_safe(struct tcp_pcb *tcpPCB, const ip_addr_t *ipaddr, u16_t port, tcp_connected_fn connected);
+ u8_t pbuf_free_safe(struct pbuf *p);
+ err_t tcp_bind_safe(struct tcp_pcb *tcpPCB, const ip_addr_t *ipaddr, u16_t port);
diff --git a/host/sdk-patches/README.md b/host/sdk-patches/README.md
new file mode 100644
index 0000000..d513837
--- /dev/null
+++ b/host/sdk-patches/README.md
@@ -0,0 +1,54 @@
+# Moddable SDK patches (machine-local)
+
+Unlike `host/patches/` (which apply to the stack-chan checkout), these apply
+to the **Moddable SDK** checkout itself (`$MODDABLE`, tested against 8.2.3):
+
+```sh
+cd "$MODDABLE"
+git apply /path/to/stackchan-matchday/host/sdk-patches/0001-esp32-tcp-clear-callbacks-safe.patch
+```
+
+Then rebuild and reflash the host.
+
+## 0001-esp32-tcp-clear-callbacks-safe.patch
+
+Fixes spontaneous `RTC_SW_CPU_RST` reboots caused by a use-after-free race in
+the ECMA-419 TCP socket glue: `doClose`/`xs_tcp_destructor` (XS task) cleared
+lwIP callbacks with raw `tcp_recv/tcp_sent/tcp_err` calls and then freed the
+socket record while the lwIP `tcpip_thread` could still be inside
+`tcpReceive` with that record as its argument. Diagnosed from serial-console
+panics (`Guru Meditation LoadProhibited` in `tcpReceive`,
+`modules/io/socket/lwip/tcp.c`), symbolicated with
+`xtensa-esp32s3-elf-addr2line`; 24 such reboots captured in one evening of
+ordinary watcher traffic.
+
+The patch fixes two independent races behind the same panic signature
+(discussed with the maintainer in
+[moddable#1655](https://github.com/Moddable-OpenSource/moddable/issues/1655)):
+
+1. **Teardown use-after-free.** `removeTCPCallbacks()` now clears the lwIP
+ callback argument first (`tcp_arg(pcb, NULL)`) and then the callbacks —
+ plain pointer stores, per maintainer guidance; no tcpip-thread marshaling
+ needed. `tcpReceive`/`tcpSent`/`tcpError` gain NULL-argument guards so any
+ late delivery observes NULL and bails. (An earlier revision marshaled the
+ clear via `tcpip_api_call`; soak testing showed the simpler arg-first
+ variant is sufficient. The unused `tcp_clear_callbacks_safe` helper may
+ remain in modLwipSafe from that revision.)
+2. **Receive-buffer list race.** `tcp->buffers` was appended to by the lwIP
+ task (`tcpReceive` walks to the tail) while the XS task pops and frees
+ head nodes in `xs_tcp_read` — with no synchronization, the tail walk can
+ traverse a node mid-free. Both sides now do their pointer surgery inside
+ `builtinCriticalSection` (heap operations stay outside the critical
+ section). This was the direct cause of the `LoadProhibited` panics at the
+ `walker->next` walk and kept crashing hosts that had only fix 1.
+
+Soak evidence: the device previously crashed every 15–25 minutes under
+ordinary watcher traffic; with both fixes it has run clean for hours
+(instrumented via the abort hook so app-level restarts are separable).
+
+Reported and fixed upstream: issue
+[moddable#1655](https://github.com/Moddable-OpenSource/moddable/issues/1655),
+PR [moddable#1656](https://github.com/Moddable-OpenSource/moddable/pull/1656)
+(same two changes). Once the PR lands, drop this patch and update the SDK
+instead. `mcconfig` rebuilds pick the change up automatically; the mod does
+not need to be rebuilt.
diff --git a/mod/audio.js b/mod/audio.js
index 6d67713..cd1f9e4 100644
--- a/mod/audio.js
+++ b/mod/audio.js
@@ -21,6 +21,12 @@ const TTS_DOMAIN = 'tts'
const TTS_DEFAULT_PORT = 8787
const TTS_SAMPLE_RATE = 24000
const TTS_VOLUME = 0.7
+// 600ms (the host default) is not enough headroom: XS busy bursts while
+// speaking starve the streamer, and every excursion past the buffer is an
+// audible mid-sentence dropout. The LAN TTS server delivers the whole WAV far
+// faster than realtime, so a deeper buffer costs memory (~72KB at 24kHz) but
+// no perceptible start latency.
+const TTS_BUFFER_MS = 1500
// Tone patterns per named reaction: [hz, ms] pairs played in order.
const TONE_CLIPS = new Map([
@@ -107,13 +113,27 @@ function ensureRemoteTts() {
if (!target.host) return undefined
const key = `${target.host}:${target.port}`
if (remoteTts === undefined || remoteTtsKey !== key) {
- const { TTS } = Modules.importNow('tts-remote')
- remoteTts = new TTS({
- host: target.host,
- port: target.port,
- sampleRate: TTS_SAMPLE_RATE,
- volume: TTS_VOLUME,
- })
+ try {
+ const { RemoteTTS } = Modules.importNow('matchday/tts-remote-safe')
+ remoteTts = new RemoteTTS({
+ host: target.host,
+ port: target.port,
+ sampleRate: TTS_SAMPLE_RATE,
+ volume: TTS_VOLUME,
+ bufferDuration: TTS_BUFFER_MS,
+ })
+ } catch (error) {
+ // Host without the audio modules the vendored streamer needs: keep the
+ // pre-1.6 behavior and let the host's own tts-remote decide.
+ trace(`[matchday] vendored tts unavailable (${error}); using host tts-remote\n`)
+ const { TTS } = Modules.importNow('tts-remote')
+ remoteTts = new TTS({
+ host: target.host,
+ port: target.port,
+ sampleRate: TTS_SAMPLE_RATE,
+ volume: TTS_VOLUME,
+ })
+ }
remoteTtsKey = key
}
return remoteTts
diff --git a/mod/commands.js b/mod/commands.js
index 8b6bf3c..1adf7c4 100644
--- a/mod/commands.js
+++ b/mod/commands.js
@@ -207,6 +207,7 @@ export function statusPayload() {
network: networkPayload(),
lastCommand: state.lastCommand,
lastError: state.lastError,
+ lastAbort: state.diagnostics.lastAbort ?? '',
}
}
diff --git a/mod/http-server-safe.js b/mod/http-server-safe.js
index 6616ed9..8ed5e44 100644
--- a/mod/http-server-safe.js
+++ b/mod/http-server-safe.js
@@ -4,9 +4,18 @@
// and it leaves respondWith() rejections unhandled when a client disconnects.
// Either failure aborts the complete XS runtime, including the touch handlers.
import Headers from 'headers'
-import listen from 'listen'
+import listen from 'matchday/listen-safe'
+import Timer from 'timer'
import { URLSearchParams } from 'url'
+// A spoken alert holds its /api/command handler for the whole playback, so the
+// watchdog must outlast the longest legitimate response.
+const REQUEST_TIMEOUT_MS = 45_000
+// lwIP's TCP pcb pool also serves the outbound TTS stream; shed load before
+// queued requests can exhaust it.
+const MAX_ACTIVE_REQUESTS = 6
+const LISTENER_RESTART_DELAY_MS = 1_000
+
class Request {
raw
#bodyResult
@@ -177,38 +186,90 @@ class HttpServerService {
delete = (path, handler) => this.#routes.delete.set(path, handler)
options = (path, handler) => this.#routes.options.set(path, handler)
+ #active = 0
+
constructor(options = {}) {
- this.#listen(options.port).catch((error) => {
- trace(`[matchday] HTTP listener stopped: ${error}\n`)
- })
+ this.#run(options.port)
+ }
+
+ async #run(port) {
+ // Listener-level errors (e.g. out of pcbs during a burst) reject the
+ // accept iterator; without a restart the server would stay dead until
+ // the next reboot.
+ for (;;) {
+ try {
+ await this.#listen(port)
+ } catch (error) {
+ trace(`[matchday] HTTP listener stopped: ${error}\n`)
+ }
+ await new Promise((resolve) => Timer.set(resolve, LISTENER_RESTART_DELAY_MS))
+ trace('[matchday] HTTP listener restarting\n')
+ }
}
async #listen(port) {
+ // Never await request handling here: one request whose body stalls (a
+ // phone that left WiFi mid-POST) or whose handler is slow (a spoken alert)
+ // must not stop every other client from being served.
for await (const connection of listen({ port })) {
- let context
- let response
+ this.#serve(connection)
+ }
+ }
+ #serve(connection) {
+ if (this.#active >= MAX_ACTIVE_REQUESTS) {
+ // respondWith would wait for the full request body, which a wedged peer
+ // never delivers; dropping the connection is the only safe shed.
try {
- context = new Context(connection.request)
- const routes = this.#routes[context.req.method]
- const handler = routes ? routes.get(context.req.path) : undefined
- response = handler ? await handler(context) : context.text('Resource Not Found', 404)
- if (!response) response = context.text('Empty Response', 500)
- } catch (error) {
- trace(`[matchday] HTTP handler failed: ${error}\n`)
- response = context ? context.text('Internal Server Error', 500) : new Response('Internal Server Error', { status: 500 })
- }
+ connection.close()
+ } catch (_closeError) {}
+ return
+ }
+ this.#active++
+
+ let settled = false
+ const finish = () => {
+ if (settled) return
+ settled = true
+ this.#active--
+ Timer.clear(watchdog)
+ }
+ const watchdog = Timer.set(() => {
+ if (settled) return
+ trace('[matchday] HTTP request timed out; closing connection\n')
+ // Closing rejects the listener's pending request/response promises, so
+ // the in-flight #handle chain settles through its rejection path.
+ try {
+ connection.close()
+ } catch (_closeError) {}
+ finish()
+ }, REQUEST_TIMEOUT_MS)
- // Keep accepting queued requests while the response is written, but
- // attach the rejection handler synchronously so a dropped phone/browser
- // connection can never become an unhandled promise rejection.
- connection.respondWith(response).catch((error) => {
- trace(`[matchday] HTTP response closed: ${error}\n`)
- try {
- connection.close()
- } catch (_closeError) {}
- })
+ this.#handle(connection).then(finish, (error) => {
+ trace(`[matchday] HTTP response closed: ${error}\n`)
+ try {
+ connection.close()
+ } catch (_closeError) {}
+ finish()
+ })
+ }
+
+ async #handle(connection) {
+ let context
+ let response
+
+ try {
+ context = new Context(connection.request)
+ const routes = this.#routes[context.req.method]
+ const handler = routes ? routes.get(context.req.path) : undefined
+ response = handler ? await handler(context) : context.text('Resource Not Found', 404)
+ if (!response) response = context.text('Empty Response', 500)
+ } catch (error) {
+ trace(`[matchday] HTTP handler failed: ${error}\n`)
+ response = context ? context.text('Internal Server Error', 500) : new Response('Internal Server Error', { status: 500 })
}
+
+ await connection.respondWith(response)
}
}
diff --git a/mod/listen-safe.js b/mod/listen-safe.js
new file mode 100644
index 0000000..5b778c7
--- /dev/null
+++ b/mod/listen-safe.js
@@ -0,0 +1,260 @@
+/*
+ * Copyright (c) 2021-2025 Moddable Tech, Inc.
+ *
+ * This file is part of the Moddable SDK Runtime.
+ *
+ * The Moddable SDK Runtime is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * The Moddable SDK Runtime is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with the Moddable SDK Runtime. If not, see .
+ *
+ */
+
+// Vendored from $(MODDABLE)/examples/io/listener/listen/listen.js (LGPL-3.0,
+// notice above) so the mod does not depend on the host's copy. Two guards are
+// added, marked [matchday]: the request and response promises are observed as
+// soon as they are created. In the upstream module a connection that errors
+// before the app consumes it (a shed connection, or a peer reset while a
+// handler is still running) rejects promises that have no reaction attached
+// yet; on a release build XS turns that into a silent device restart
+// ("unhandled rejection" abort).
+
+import HTTPServer from "embedded:network/http/server"
+import Listener from "embedded:io/socket/listener";
+
+import Headers from "headers";
+import URL from "url";
+
+class Request {
+ #url;
+ #method;
+ #headers;
+ #body;
+ constructor(url, options) {
+ this.#url = url;
+ this.#method = options.method;
+ this.#headers = options.headers;
+ this.#body = options.body;
+ }
+ get bodyUsed() {
+ return this.#body ? false : true;
+ }
+ get headers() {
+ return this.#headers;
+ }
+ get method() {
+ return this.#method;
+ }
+ get url() {
+ return this.#url;
+ }
+ async arrayBuffer() {
+ let body = this.#body;
+ if (body) {
+ this.#body = undefined;
+ body = await body;
+ }
+ return body;
+ }
+ async json() {
+ let body = this.#body;
+ if (body) {
+ this.#body = undefined;
+ body = await body;
+ if (body) {
+ body = String.fromArrayBuffer(body);
+ return JSON.parse(body);
+ }
+ }
+ return body;
+ }
+ async text() {
+ let body = this.#body;
+ if (body) {
+ this.#body = undefined;
+ body = await body;
+ if (body)
+ body = String.fromArrayBuffer(body);
+ }
+ return body;
+ }
+}
+
+class Response {
+ #body;
+ #headers;
+ #status = 200;
+ constructor(body, options) {
+ if (!(body instanceof ArrayBuffer)) {
+ body = body.toString();
+ body = ArrayBuffer.fromString(body);
+ }
+ this.#body = body;
+
+ const headers = (options && options.headers) ? options.headers : new Headers;
+ if ((headers.get("content-length") == undefined)) {
+ headers.set("content-length", body.byteLength);
+ }
+ this.#headers = headers;
+
+ this.#status = (options && options.status) ? options.status : 200;
+ }
+ get body() {
+ return this.#body;
+ }
+ get headers() {
+ return this.#headers;
+ }
+ get status() {
+ return this.#status;
+ }
+ async arrayBuffer() {
+ let body = this.#body;
+ if (body) {
+ this.#body = undefined;
+ body = await body;
+ }
+ return body;
+ }
+ async json() {
+ let body = this.#body;
+ if (body) {
+ this.#body = undefined;
+ body = await body;
+ body = String.fromArrayBuffer(body);
+ return JSON.parse(body);
+ }
+ return body;
+ }
+ async text() {
+ let body = this.#body;
+ if (body) {
+ this.#body = undefined;
+ body = await body;
+ body = String.fromArrayBuffer(body);
+ }
+ return body;
+ }
+}
+
+async function* listen(options) {
+ const connectionQueue = [];
+ const promiseQueue = [];
+ const port = options?.port ?? 80;
+ const base = "http://localhost:" + port;
+ new HTTPServer({
+ io: Listener,
+ port,
+ onConnect(_connection_) {
+ let requestPromise = new Promise((resolveRequest, rejectRequest) => {
+ let requestBody = null;
+ let responsePromise = new Promise((resolveResponse, rejectResponse) => {
+ let responseBody = null;
+ let offset = 0;
+ let length = 0;
+ _connection_.accept({
+ onRequest(_request_) {
+ const { method, path, headers } = _request_;
+ const request = new Request(new URL(path, base), { method, path, headers, body:requestPromise });
+ const connection = {
+ close() {
+ _connection_.close();
+ },
+ async respondWith(response) {
+ response = await response;
+ responseBody = await response.arrayBuffer();
+ if (responseBody)
+ length = responseBody.byteLength;
+ const _response_ = await responsePromise;
+ _response_.status = response.status;
+ _response_.headers = response.headers;
+ _connection_.respond(_response_);
+ }
+ };
+ Object.defineProperty(connection, "request", { value:request, enumerable:true }); // read only
+ if (promiseQueue.length == 0) {
+ connectionQueue.push(connection);
+ }
+ else {
+ const promise = promiseQueue.shift();
+ promise.resolveEvent(connection);
+ }
+ },
+ onReadable(count) {
+ if (requestBody)
+ requestBody = requestBody.concat(this.read(count));
+ else
+ requestBody = this.read(count);
+ },
+ onResponse(_response_) {
+ resolveRequest(requestBody);
+ resolveResponse(_response_);
+ },
+ onWritable(count) {
+ if (responseBody) {
+ if (length > 0) {
+ if (count > length)
+ count = length;
+ let view = new DataView(responseBody, offset, count);
+ this.write(view);
+ offset += count;
+ length -= count;
+ }
+ else
+ this.write();
+ }
+ else
+ this.write();
+ },
+ onError(error) {
+ rejectRequest(error);
+ rejectResponse(error);
+ }
+ });
+ });
+ // [matchday] Observe the response promise as soon as it exists.
+ // onError rejects it whenever the peer resets, including while a
+ // handler is still running (before respondWith attaches) or when
+ // the server sheds the connection without ever consuming it.
+ responsePromise.then(undefined, () => {});
+ });
+ // [matchday] Same guard for the request promise: a shed or
+ // never-consumed connection has no Request wrapper observing it.
+ requestPromise.then(undefined, () => {});
+ },
+ onError(message) {
+ const error = new Error(message);
+ if (promiseQueue.length == 0) {
+ connectionQueue.push(error);
+ }
+ else {
+ const promise = promiseQueue.shift();
+ promise.rejectEvent(error);
+ }
+ }
+ })
+ while (true) {
+ yield new Promise((resolveEvent, rejectEvent) => {
+ if (connectionQueue.length == 0)
+ promiseQueue.push({ resolveEvent, rejectEvent });
+ else {
+ const connection = connectionQueue.shift();
+ if (connection instanceof Error)
+ rejectEvent(connection);
+ else
+ resolveEvent(connection);
+ }
+ })
+ }
+}
+
+export { Headers, Response }
+export default listen;
diff --git a/mod/manifest.json b/mod/manifest.json
index 8bcdd03..345b83d 100644
--- a/mod/manifest.json
+++ b/mod/manifest.json
@@ -7,6 +7,8 @@
"matchday/audio": "./audio",
"matchday/commands": "./commands",
"matchday/http-server-safe": "./http-server-safe",
+ "matchday/listen-safe": "./listen-safe",
+ "matchday/tts-remote-safe": "./tts-remote-safe",
"matchday/web": "./web"
},
"resources": {
diff --git a/mod/mod.js b/mod/mod.js
index 2f24443..4444885 100644
--- a/mod/mod.js
+++ b/mod/mod.js
@@ -20,6 +20,7 @@ import {
nowTicks,
readPreference,
safeNetGet,
+ savePreference,
state,
toNumber,
MIN_IDLE_LOOK_INTERVAL_MS,
@@ -39,6 +40,20 @@ import {
import { runCommand } from 'matchday/commands'
import { startHttp } from 'matchday/web'
+// Release hosts restart on uncaught exceptions with no console trace left
+// behind (fxAbort -> esp_restart). With the host's MODDEF_XS_ABORTHOOK, this
+// hook runs first: record why we died so /api/status can report it after the
+// reboot. Returning false lets the restart proceed.
+globalThis.abort = function (status, exception) {
+ try {
+ const reason = `${status ?? 'abort'}: ${exception ?? ''}`.slice(0, 160)
+ savePreference('lastAbort', reason)
+ } catch (_error) {
+ // never throw from the abort hook
+ }
+ return false
+}
+
const SETUP_POWER_DEBOUNCE_MS = 600
const SETUP_TOP_TAP_MAX_MS = 500
const SETUP_TOP_MIN_INTER_TAP_MS = 120
@@ -106,6 +121,15 @@ function restoreSettings(robot) {
state.matchSetup.language = pending.language
}
}
+
+ const lastAbort = String(readPreference('lastAbort', ''))
+ if (lastAbort) {
+ state.diagnostics.lastAbort = lastAbort
+ trace(`[matchday] previous run aborted: ${lastAbort}\n`)
+ // Consume the record so /api/status only ever reports an abort of the
+ // run immediately before this boot, not one from days ago.
+ savePreference('lastAbort', undefined)
+ }
trace('[matchday] settings restored\n')
} catch (error) {
trace(`[matchday] settings restore failed ${error}\n`)
diff --git a/mod/state.js b/mod/state.js
index ad039f3..50d39b7 100644
--- a/mod/state.js
+++ b/mod/state.js
@@ -4,7 +4,7 @@ import Net from 'net'
import Time from 'time'
export const MOD_NAME = 'stackchan_matchday'
-export const MOD_VERSION = '1.5.0'
+export const MOD_VERSION = '1.6.0'
export const PREF_DOMAIN = 'stackchan'
export const COMMENTARY_STYLES = ['casual', 'balanced', 'professional']
@@ -95,6 +95,7 @@ export const state = {
matchSetup: {
language: 'zh',
commentaryStyle: 'balanced',
+ spoilerFreeMode: false,
options: [],
current: {},
pending: null,
diff --git a/mod/tts-remote-safe.js b/mod/tts-remote-safe.js
new file mode 100644
index 0000000..5b6e640
--- /dev/null
+++ b/mod/tts-remote-safe.js
@@ -0,0 +1,97 @@
+// Remote-TTS streamer vendored from stack-chan's speeches/tts-remote.ts so the
+// audio buffer depth is tunable from the mod. The host pins bufferDuration to
+// 600ms; XS-thread busy bursts while speaking (balloon marquee, HTTP handling)
+// can exceed that, and every excursion is an audible mid-sentence dropout
+// (WavStreamer traces "Ready: false" / "Ready: true" around each one — two per
+// long sentence in the 2026-07-14 baseline capture). The TTS server sends a
+// complete WAV over the LAN, so a deeper buffer fills in well under realtime
+// and start latency is unaffected.
+//
+// Host audio modules are resolved lazily; on a host without them the
+// constructor throws and the caller falls back to the host's own tts-remote.
+import Modules from 'modules'
+
+const DEFAULT_SAMPLE_RATE = 24000
+const DEFAULT_VOLUME = 0.5
+const DEFAULT_BUFFER_MS = 1500
+
+class RemoteTTS {
+ #AudioOut
+ #WavStreamer
+ #calculatePower
+
+ constructor(props) {
+ this.onPlayed = props.onPlayed
+ this.onDone = props.onDone
+ this.streaming = false
+ this.host = props.host
+ this.port = props.port
+ this.sampleRate = props.sampleRate ?? DEFAULT_SAMPLE_RATE
+ this.volume = props.volume ?? DEFAULT_VOLUME
+ this.bufferDuration = props.bufferDuration ?? DEFAULT_BUFFER_MS
+
+ this.#AudioOut = Modules.importNow('pins/audioout')
+ this.#WavStreamer = Modules.importNow('wavstreamer')
+ if (this.onPlayed) this.#calculatePower = Modules.importNow('calculate-power')
+ }
+
+ async stream(key, volume) {
+ if (this.streaming) {
+ throw new Error('already playing')
+ }
+ this.streaming = true
+ const { onPlayed, onDone } = this
+ const calculatePower = this.#calculatePower
+ return new Promise((resolve, reject) => {
+ // WavStreamer splits the buffer into 8 blocks and each block occupies
+ // two queue slots (samples + callback) while it keeps 2 slots free, so
+ // the default queueLength of 8 caps the in-flight audio at about half of
+ // bufferDuration. 16 slots let the full depth actually queue.
+ this.audio = new this.#AudioOut({ streams: 1, sampleRate: this.sampleRate, queueLength: 16 })
+ this.audio.enqueue(0, this.#AudioOut.Volume, Math.round((volume ?? this.volume) * 256))
+ const audio = this.audio
+ let streamer
+ streamer = new this.#WavStreamer({
+ http: device.network.http,
+ host: this.host,
+ path: key,
+ port: this.port,
+ bufferDuration: this.bufferDuration,
+ audio: {
+ out: audio,
+ stream: 0,
+ },
+ onPlayed(buffer) {
+ if (onPlayed && calculatePower) onPlayed(calculatePower(buffer))
+ },
+ onReady(state) {
+ trace(`Ready: ${state}\n`)
+ if (state) {
+ audio.start()
+ } else {
+ audio.stop()
+ }
+ },
+ onError: (e) => {
+ trace('ERROR: ', e, '\n')
+ this.streaming = false
+ streamer?.close()
+ this.audio?.close()
+ this.audio = undefined
+ reject(e)
+ },
+ onDone: () => {
+ trace('DONE\n')
+ this.streaming = false
+ streamer?.close()
+ this.audio?.close()
+ this.audio = undefined
+ onDone?.()
+ resolve()
+ },
+ })
+ })
+ }
+}
+
+export { RemoteTTS }
diff --git a/mod/web.js b/mod/web.js
index c88e210..7694534 100644
--- a/mod/web.js
+++ b/mod/web.js
@@ -41,6 +41,15 @@ function applyCommentaryStyle(commentaryStyle) {
return true
}
+function applySpoilerFreeMode(spoilerFreeMode) {
+ if (typeof spoilerFreeMode !== 'boolean') return false
+ if (state.matchSetup.spoilerFreeMode !== spoilerFreeMode) {
+ state.matchSetup.spoilerFreeMode = spoilerFreeMode
+ savePreference('spoilerFreeMode', spoilerFreeMode)
+ }
+ return true
+}
+
function response(value, status, contentType) {
const body = ArrayBuffer.fromString(String(value))
return new Response(body, {
@@ -84,6 +93,7 @@ function matchSetupPayload() {
ok: true,
language: state.matchSetup.language,
commentary_style: state.matchSetup.commentaryStyle,
+ spoiler_free_mode: state.matchSetup.spoilerFreeMode,
options: state.matchSetup.options,
current: state.matchSetup.current,
pending: state.matchSetup.pending,
@@ -100,6 +110,7 @@ function syncMatchSetup(payload) {
}
applyLanguage(payload?.language ?? state.matchSetup.current?.language, true)
applyCommentaryStyle(payload?.commentary_style ?? state.matchSetup.current?.commentary_style)
+ applySpoilerFreeMode(payload?.spoiler_free_mode ?? state.matchSetup.current?.spoiler_free_mode)
return { ok: true, text: `ok match setup options ${state.matchSetup.options.length}\n` }
}
@@ -124,6 +135,24 @@ function queueCommentaryStyle(payload) {
return { ok: true, text: 'ok commentary style queued\n', pending }
}
+function queueSpoilerFreeMode(payload) {
+ if (typeof payload?.spoiler_free_mode !== 'boolean') {
+ return { ok: false, status: 400, text: 'error spoiler_free_mode must be a boolean\n' }
+ }
+ if (state.matchSetup.pending) {
+ return { ok: false, status: 409, text: 'error another match setup request is pending\n' }
+ }
+ const pending = {
+ request_id: String(payload?.request_id ?? nowTicks()),
+ spoiler_only: true,
+ spoiler_free_mode: payload.spoiler_free_mode,
+ }
+ state.matchSetup.pending = pending
+ state.matchSetup.lastResult = null
+ savePreference('matchSetupPending', JSON.stringify(pending))
+ return { ok: true, text: 'ok spoiler-free mode queued\n', pending }
+}
+
function queueMatchSetup(payload) {
const language = String(payload?.language ?? state.matchSetup.language)
.trim()
@@ -139,9 +168,21 @@ function queueMatchSetup(payload) {
if (!COMMENTARY_STYLES.includes(commentaryStyle)) {
return { ok: false, status: 400, text: 'error commentary_style must be casual, balanced, or professional\n' }
}
- // A full setup can absorb a style-only request because it carries the same
- // global preference. Never overwrite an unrelated match/market request.
- if (state.matchSetup.pending && !state.matchSetup.pending.style_only) {
+ if (payload?.spoiler_free_mode !== undefined && typeof payload.spoiler_free_mode !== 'boolean') {
+ return { ok: false, status: 400, text: 'error spoiler_free_mode must be a boolean\n' }
+ }
+ // Legacy full-setup clients may omit the new preference. Preserve a pending
+ // phone toggle across the immediate full apply, otherwise keep the current
+ // effective value.
+ const spoilerFreeMode =
+ typeof payload?.spoiler_free_mode === 'boolean'
+ ? payload.spoiler_free_mode
+ : typeof state.matchSetup.pending?.spoiler_free_mode === 'boolean'
+ ? state.matchSetup.pending.spoiler_free_mode
+ : state.matchSetup.spoilerFreeMode
+ // A full setup can absorb a preference-only request because it carries the
+ // same global settings. Never overwrite an unrelated match/market request.
+ if (state.matchSetup.pending && !state.matchSetup.pending.style_only && !state.matchSetup.pending.spoiler_only) {
return { ok: false, status: 409, text: 'error another match setup request is pending\n' }
}
@@ -158,6 +199,7 @@ function queueMatchSetup(payload) {
kalshi_url: kalshiUrl,
language,
commentary_style: commentaryStyle,
+ spoiler_free_mode: spoilerFreeMode,
}
state.matchSetup.pending = pending
state.matchSetup.lastResult = null
@@ -192,6 +234,7 @@ function queueMatchSetup(payload) {
}
pending.language = language
pending.commentary_style = commentaryStyle
+ pending.spoiler_free_mode = spoilerFreeMode
state.matchSetup.pending = pending
state.matchSetup.lastResult = null
savePreference('matchSetupPending', JSON.stringify(pending))
@@ -215,9 +258,13 @@ function acknowledgeMatchSetup(payload) {
if (state.matchSetup.lastResult && pending?.style_only) {
state.matchSetup.lastResult.style_only = true
}
+ if (state.matchSetup.lastResult && pending?.spoiler_only) {
+ state.matchSetup.lastResult.spoiler_only = true
+ }
if (payload?.ok) {
applyLanguage(payload?.language)
applyCommentaryStyle(payload?.commentary_style)
+ applySpoilerFreeMode(payload?.spoiler_free_mode)
}
savePreference('matchSetupPending', undefined)
return { ok: true, text: 'ok match setup acknowledged\n' }
@@ -332,6 +379,7 @@ function setupPageHtml() {
+
@@ -339,8 +387,8 @@ function setupPageHtml() {