Skip to content

feat(renderer): report forwardFrames delivery state via forwardStatus - #85

Merged
naporin0624 merged 5 commits into
mainfrom
feat/forward-status-observability
Aug 14, 2026
Merged

naporin0624 merged 5 commits into
mainfrom
feat/forward-status-observability

Conversation

@naporin0624

@naporin0624 naporin0624 commented Aug 14, 2026 •

Copy link
Copy Markdown
Owner

なぜ

Cannelloni で「プレビューだけが無言で死ぬ」事故を実機計測で確認した。3 時間のセッションでも、5 回の起動中 3 回でも再現した状態は次のとおり:

計測点 故障時 正常時
OSR paint 正常 25fps (600枚) 正常
frameDropped / error 0 件 0 件
sharedTexture.sendSharedTexture 一度も呼ばれない 300 回/10 秒
renderer に届いたフレーム 0 25fps
main プロセスのログ 例外なし 例外なし

paint も native sender も preview も droppedReason も全部健全なまま、監視ウィンドウだけが黙って黒くなる。切り分けようにも materials が無い:

  • 毎フレームの ForwardDefect は handlePaint の .catch(() => {}) で捨てられている
  • 登録の拒否 (bridge が dispose 済み / target が破壊済み) は inert handle として黙殺され、生きている handle と見分けがつかない

調査には計測コードの注入が必要で、しかも計装するとレースが消えた (計装前 3/5 失敗 → 計装後 22/22 成功)。ライブラリが状態を報告しない限り、この故障は現場で追えない。

なにを

forwardStatus イベント + FrameForwardOptions.onStatus

どちらも 状態遷移だけ を通知する — 初回成功 / 初回失敗 / 失敗理由の変化 / 復帰。30fps で同じ状態を垂れ流さない (frameDropped と同じ dedupe 方針)。

type ForwardStatus =
  | { ok: true }
  | { ok: false; reason: "target-destroyed" | "import-failed" | "send-failed"; cause?: Error };

// bridge 全体で受ける (どの登録かは extraArgs で識別)
bridge.on("forwardStatus", (s) => log(`slot ${s.extraArgs[0]}`, s.ok ? "ok" : s.reason));

// 登録単位で受ける
bridge.forwardFrames(target, { extraArgs: [slot], onStatus: (s) => log(slot, s) });

遷移だけを出すので、ログを素直に読むだけで停止区間になる: { ok: false } の後に { ok: true } が無ければ、そのターゲットはその時刻から今も暗い。

FrameForward.active

登録が成立したかどうか。set のメンバーシップを見る getter なので、登録拒否・dispose()・target の後からの破壊をすべて反映する。

const fwd = bridge.forwardFrames(target, { extraArgs: [slot] });
if (!fwd.active) log(`slot ${slot} was refused — no frames will ever arrive`);

設計上の判断

  • "error" イベントは使わない。 購読者ゼロだと throw するため、まさに「誰も見ていない」状況を生き延びる必要がある診断チャネルには不適。
  • 購読側の throw を握る。 両 sink は promise continuation の中で走るので、逃げた例外は main プロセスの unhandled rejection になる。どちらの sink も転送を止めてはいけない (console.error に落とす)。
  • reject は send-failed として報告。 import 側は primitive が Result で包んでいるので、reject し得るのは send 側だけ。
  • dispose 後に着弾した結果は捨てる。 誰も所有していない登録の状態をログに復活させない。

テスト

新規 9 件 (renderer 201 件 / core 33 件すべてパス)。dedupe・復帰・理由変化・reject 経路・in-flight dispose・active の 4 状態を個別に固定した。

typecheck クリーン、lint 0 errors、oxfmt 済み。

ドキュメント

docs/API.md と 3 つの skill に「defect は driver が捨てる」「登録は無言」と書いてあり、この PR で嘘になるため全部更新した。

🤖 Generated with Claude Code

https://claude.ai/code/session_01TErgwokxkbfxexfUNeuMoW


追記: diagnosing-dead-frame-forwards skill

同じ事故に次に当たった人が 2 手で層を特定できるよう、診断 skill を TDD で追加した。

RED (skill 無しのベースライン, 2 エージェント): どちらも根拠の薄い仮説から始めた。片方は in-flight promise の積み上がりを疑い、ライブラリが既に公開している信号を読む前に handlePaint へ pending カウンタを足すと提案した。両者とも FrameForward.active に触れず、「アイドルなソースはそもそも paint を出さない」を考慮せず、「フリーズ画像 / 空白プレースホルダ」の弁別も使わなかった。

GREEN (skill 有り): Syphon が生きている事実から「死んだのは転送だけ」と推論し、active を確認し、4 つの結果すべてに分岐を用意し、paint ループへの一時プローブ追加を明示的に見送った。

skill には計測済みの警告も入れた — 計装するとこのバグは隠れる (実測: 再現率 3/5 → 0/22)。

naporin0624 and others added 4 commits August 14, 2026 18:19
The delivery state of a forwardFrames registration was unobservable. Per-frame
defects were dropped by a `.catch(() => {})` in handlePaint, and a refused
registration was returned as an inert handle indistinguishable from a live one.
A forward could therefore die and stay dead while paint, the native sender, the
preview window and `droppedReason` all stayed healthy — the only symptom being a
monitor window that silently went black.

- Add the `forwardStatus` event and `FrameForwardOptions.onStatus`. Both report
  state *changes* only — first success, first failure, a change of failure
  reason, and recovery — so a stuck target cannot emit 30 identical events a
  second (same dedupe stance as `frameDropped`).
- Add `FrameForward.active`, backed by set membership, so a refused
  registration, a `dispose()` and a destroyed target are all visible to the
  caller.
- Route a rejected forward to `send-failed`: the import half is already
  Result-wrapped by the primitive, so only the send half can reject.
- Swallow throws from either observer. Both run inside a promise continuation
  where an escaping throw becomes an unhandled rejection, and neither may break
  forwarding; the `"error"` event is deliberately not used, since it throws
  when nobody is listening — exactly the case a diagnostic channel must survive.
- Update API.md and three skills, which documented the now-false claims that
  forward defects are discarded and that registration is silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TErgwokxkbfxexfUNeuMoW
Baseline: two agents were handed the real incident (some forwarded previews
dead for the session, Syphon unaffected, empty logs) without a skill. Both
opened with an invented hypothesis — one proposed unbounded in-flight promise
pile-up and wanted to patch `handlePaint` with a pending counter before
reading anything the library already publishes. Neither mentioned
`FrameForward.active`, neither considered that an idle source emits no paint
at all, and neither used the frozen-frame vs blank-placeholder distinction
that splits the search space in half.

The skill closes exactly those gaps: read `active` and `forwardStatus` first
and let the outcome table name the layer, confirm the source is actually
painting before blaming the forward path, and read the symptom (frozen vs
blank) to pick a side. It also carries the measured warning that probing the
paint loop hides this bug — instrumentation moved a real reproduction from
3-in-5 launches to 0-in-22.

With the skill, a re-run reasoned from Syphon's health to "only forwarding
died", checked `active`, planned around all four outcomes, and explicitly
declined to add a temporary paint-loop probe.

Also cross-links from the two neighbouring skills and lists the skill in both
READMEs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TErgwokxkbfxexfUNeuMoW
The second verification run found the loophole: it checked the consumer's
installed `.d.mts`, saw that the pinned 0.14.0 exposes neither
`FrameForward.active` nor `forwardStatus`, and concluded the skill's primary
diagnostic was unavailable — then spent its plan designing a substitute out of
`frameDropped` / `error` and hand-rolled call-site logging, none of which can
observe the forward path at all.

State the version gate explicitly: detect it from the installed types rather
than the checked-out source, treat the upgrade as the fix rather than a
prerequisite, and say plainly that the fallback seams are coarse and blind to
forwarding — so nobody invests in the substitute again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TErgwokxkbfxexfUNeuMoW
Verifying a library change against the consumer that motivated it currently
requires merging and releasing first — the publish job only runs on a
release-please release, and it publishes through npm OIDC trusted publishing,
which cannot be driven from a branch. That ordering is backwards for a change
whose whole purpose is to make a consumer-side failure observable.

pkg.pr.new serves an installable tarball per commit from its own host, so no
version is bumped and nothing reaches the npm registry.

Only the renderer package is published: core and the native addon are
unchanged by renderer-only work, and leaving native out keeps the Rust matrix
off the PR path. `--pnpm` is what makes the single-package publish coherent —
`pnpm pack` rewrites the `workspace:*` dependency on core to its concrete
version so the preview resolves it from npm, where `npm pack` would leave the
protocol literal and produce an uninstallable tarball.

Requires the pkg.pr.new GitHub App on the repository; the workflow itself
needs no token, since the app posts the preview URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TErgwokxkbfxexfUNeuMoW
@naporin0624
naporin0624 force-pushed the feat/forward-status-observability branch from 3da6a1c to c247354 Compare August 14, 2026 09:57
@pkg-pr-new

pkg-pr-new Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@napolab/texture-bridge-renderer@85

commit: fe22098

Adding a preview-release workflow put a fourth unpinned, over-permissioned
workflow into the repository, so fix the class rather than the instance:
zizmor reported 50 findings across the four, 29 of them high severity.

- Pin all 26 action references to commit SHAs with the tag in a comment. A
  mutable tag is a supply-chain hole: whoever can move `v4` runs code in a job
  that, until this commit, held write access to the repository.
- Give every checkout `persist-credentials: false`. Nothing here pushes over
  git, so leaving the token in `.git/config` only widened what a compromised
  build step could reach.
- Move publish.yml's `contents: write` / `pull-requests: write` /
  `id-token: write` off the workflow and onto the two jobs that need them:
  release-please opens the release PR and cuts the release, publish holds the
  npm OIDC credential. The native build matrix, which runs the most
  third-party code, drops to read-only. ci.yml and platform-smoke.yml declare
  `contents: read`.
- Keep it from regressing with a Workflow Audit job, itself pinned so the
  ruleset cannot drift under a green check.

The one remaining finding is suppressed inline: zizmor reads pkg-pr-new's
"publish" as a registry upload, but that step has no credential to move to
trusted publishing — the GitHub App authorizes it and nothing reaches npm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TErgwokxkbfxexfUNeuMoW
@naporin0624
naporin0624 merged commit 89b37e2 into main Aug 14, 2026
6 checks passed
@naporin0624
naporin0624 deleted the feat/forward-status-observability branch August 14, 2026 10:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant