コンテンツにスキップ

ロボットの停止

このリポジトリで Palmimo を動かすプログラムには、停止を求められたときに果たすべき義務が 1 つあります。Palmimo.disconnect() に到達することです。その最後の文でドライバーを切断し、サーボのトルクを切ります。シャットダウンのほかの処理は、安全性に不可欠ではありません。

これを実行し損ねるのは、単なる見た目の問題ではありません。温度保護はありません。首のサーボは重力に逆らって頭を支えており、通電したまま停止すると、誰もロボットの近くにいないのに 26 °C → 45 °C → 57 °C と温度が上昇した測定例があります。

このページでは、停止の伝達方法、エントリーポイントごとに伝達方法が異なる理由、共有する処理を記録します。

停止処理は 2 つに分かれます。まず、届いたシグナルをプログラムが対処できる形に変える必要があります。これが伝達であり、プログラム自身のループが何をしているかによって 3 つの形を取ります。どの形でも、その後プログラムはスタックを巻き戻して finally に入り、shutdown.park(robot) を呼びます。この関数はそれ以降の停止シグナルを無視し、Palmimo.disconnect() を実行してトルクを切ります。

後半は、すべての呼び出し元に共通する palmimo_sdk.shutdown が担当します。前半は、実際に違いが必要な部分なので、エントリーポイントごとに置きます。

伝達方法の選択: このループは待機中に await するか?

Section titled “伝達方法の選択: このループは待機中に await するか?”
答え 形 理由
はい。await で待機します loop_stop_on_signals(loop.add_signal_handler) ハンドラーはループ内の通常のコールバックとして実行されるため、待機中のタスクのキャンセルや、セッションが監視するイベントの設定など、実際の処理を行えます。
いいえ。同期的にブロックします(マイクのキュー、ブロッキング読み取り) stop_flag_on_signals キャンセルは await で伝達されますが、このループはそこに到達しないため、未処理のままになります。ハンドラーがフラグを設定し、ループが読むタイミングを決めます。
自前のループはなく、制御はサードパーティーのランナー内にあります interrupt_on_signals Textual と uvicorn がループを所有します。シグナルを KeyboardInterrupt に変換すれば、自前の finally に制御が戻り、それで十分です。

この 3 つは、異なるのが正しい形です。すべてに同じ方法を使うと、少なくとも 1 つは壊れます。

  • ウェイクワードエージェントでは、ループ内のハンドラーは発火しません。listen_loop は Subscription.__next__ → queue.Queue.get() でブロックし、音声待ちの間は await に到達しないためです。まさにこれが、Ctrl-C でこのエージェントがまったく停止しなかった理由です(実機で 3 回中 3 回)。asyncio.Runner 自身の SIGINT ハンドラーは task.cancel() を呼びますが、キャンセルは記録されるだけで伝達されませんでした。
  • リアルタイムセッションにフラグを使うと、--seconds が経過するまで読まれません。各サービスはフラグをポーリングせず、ソケット、カメラのキュー、マイクの読み取りでブロックします。その間にサービスマネージャーの停止タイムアウトを超え、パーク(安全姿勢に戻して脱力)を実行しないままプロセスが SIGKILL されます。
  • OS ハンドラーから例外を送出すると、そのとき実行していたバイトコードの位置でスタックを巻き戻します。これでは、協調的なループ自身が判断する安全な停止位置を無視し、ハンドラーが触れるものすべてに再入の危険を生みます(別の print の途中で print が実行されると、RuntimeError: reentrant call inside <_io.BufferedWriter> が発生します)。

このページは、共有する後半と伝達方法の選択規則を説明します。このリポジトリのエントリーポイントは、まだすべてがこの仕組みに移行したわけではありません。

エントリーポイント 現状
ユーザースクリプト(with Palmimo() as robot:) 対応済みです。呼び出し元がこのモジュールに触れなくても、Palmimo.disconnect() 自身が規則を強制します。後述します。
MCP サーバー(palmimo_sdk/mcp/__main__.py) 終了シグナルでパークします。独自の interrupt_on_signals 相当の処理と _signals.signals_ignored を使います。後続の変更でこのモジュールに移行しますが、その際に動作は変わりません。
コンパニオンの TUI / CLI / リアルタイム、ウェイクワードエージェント 未対応です。それぞれ独自の停止処理を持ち、問題点も分かっています。ウェイクワードエージェントの問題は前述のとおりです。これらは 1 つずつこのモジュールへ移行します。

呼び出し元より先にモジュールを追加するのは意図的です。どこでも同一であるべき後半の処理であり、一部のエントリーポイントしか到達できない保護でも、すべてから到達できるようになる前に備える価値があります。上の表から形を選べば、移行は機械的に行えます。

STOP_SIGNALS は SIGINT、SIGTERM、SIGHUP です。

SIGHUP は珍しいものではありません。ロボットは通常 ssh 経由で動かすため、セッションが切れると届きます。ターミナルのウィンドウを閉じた場合も同様です。既定の処理は SIGTERM と同じく、何も告げずにプロセスを終了します。

Python がすでに SIGINT を KeyboardInterrupt として伝達するため、 TERMINATING_SIGNALS に SIGINT は含めません。同じ結果への経路をもう 1 つ設けても無駄な変更になります。ただし、STOP_SIGNALS には含まれ、パーク中に無視する集合にも含まれます。パークが始まった後の Ctrl-C も、トルクを切る処理を飛ばす手段になるためです。

トルクは Palmimo.connect() の最後に入ります。これは、上記のループが動き始める前です。トルクが入った区間は、21 個のサーボとのバスハンドシェイクとウェイク時の滑らかな移動に数秒かかります。以前はすべてのエントリーポイントが、その間にハンドラーを設定していませんでした。そのため、そこでシグナルが届くと既定の処理が実行され、サーボに通電したままプロセスが終了しました。したがって、停止を受け付ける区間は、接続の後ではなく前に開始する必要があります。

その区間ではフラグを設定し、キャンセルはしません。接続処理はワーカースレッドで実行され、スレッドはキャンセルできません。await を放棄すると、そのスレッドがサーボバスを操作し続ける一方で、パークも同じバスを操作し始めます。そのため、接続は完了まで実行し、次のチェックポイントで停止を反映して、誰も求めていないセッションを開かずに済ませます。接続処理のうちトルクが入る部分(バス接続とウェイク時の移動)の所要時間は有限です。それより前の、カメラを開いたり音声をダウンロードしたりする分だけ時間がかかる処理は、トルクを切って実行します。ただし、 MCP サーバーやウェイクワードエージェントのように、ポートを調べるためにファサードを作る前にドライバーを接続した場合は、その間ずっとトルクが入っています。

そのフラグをどう読むかで、区間に使う形が決まります。loop_stop_on_signals は、すでに準備完了キューにある処理の後ろに並ぶ通常のループコールバックで伝達します。そのため、割り込まれた await の直後にフラグを読むと、まだ false の場合があります。これは断続的に、順序に依存して発生します。そこで、起動区間には stop_flag_on_signals を使います。フラグは C レベルのハンドラーで設定されるため、呼び出しが戻った時点ですでに true です。ループ内の形は、ループ内で何かを実行するためのもので、チェックポイントには使いません。

割り込み不能な区間はパークだけであり、すべての経路がそこに到達します。

  • パークに到達しなければならない後処理は、最後のパークだけでなく、後処理全体を signals_ignored(*TERMINATING_SIGNALS) で囲みます。伝達用のコンテキストマネージャーを抜けると、SIGTERM/SIGHUP は SIG_DFL に戻ります。asyncio の remove_signal_handler は、置き換え前の処理によらず既定値に戻すためです。 SIG_DFL はスタックを巻き戻さずに終了します。後処理には姿勢の安定待ちとタスクのキャンセルで数秒かかるため、この隙間は現実の問題です。
  • SIGINT は意図的にこの無視の対象から外します。後処理を通り越すのではなく、後処理へスタックを巻き戻すため、タスクのキャンセル中に 2 回目の Ctrl+C を押すと、トルクを切る処理を飛ばさずにシャットダウンを短縮できます。これで、遅いシャットダウンから操作者が抜け出す手段を残せます。
  • shutdown.park() は、実行中の停止シグナルを無視します(マスクではなく SIG_IGN です。ブロックされたシグナルは保留状態で残り、ブロック解除時に発火するため、終了がパーク直後に移るだけになります)。終わらないパークから抜け出す方法は SIGKILL であり、ここで捕捉できるものでも、すべきものでもありません。
  • このモジュールに触れない呼び出し元にも、1 つ下の層の Palmimo.disconnect() が同じ規則を適用します。後処理のステップで発生した BaseException を保留し、残りのステップを実行してトルクを切った後で初めて、再送出します。

シグナルによる停止は秩序立った処理ですが、安全を保証するものではありません。

  • SIGKILL と電源断では、コードは一切実行されません。
  • パークには数秒かかります(約 2.5 s の首の解放ランプに加え、脚の復帰)。そのため、終了までの時間制限があると、その途中でプロセスが終了します。MCP stdio クライアントでは、stdin のクローズから SIGKILL まで 4.0 s の猶予があります。MCP エントリーポイントは、それに合わせてパークを短縮せず、自身のシャットダウン時間を測定し、超過したときは stderr に報告します。
  • 不正な停止後にトルクが有効のまま残ることは、許容している動作です。 README でもこれを明示し、ロボットを安全に扱える状態にする確実な方法は AC アダプターを抜くことだけだとしています。