msxterm の #save が思っていたように動かなかった話 ― そして #reload_from を実装した

みなさん、こんにちは。

前回の記事では、Haiku OS で msxterm をビルドするまでをお届けしました。

今回は移植のお話ではなく、msxterm 自体の機能に関する話題です。

msxterm を常用している方はひょっとするとご存じなのかもしれませんが、#save コマンドを検証している過程で「ファイルが保存できない場合にアプリごと落ちてしまう」というバグと、「そもそも #save が保存する内容が MSX0 の実態とズレてしまう」という設計上の問題が見つかりました。

今回はこの両方をまとめて修正した記録をご紹介します!


#save とは何を保存するコマンドか?

msxterm には「プログラムバッファ」という概念があります。ターミナル側で行番号付きの行を入力したり、#load でファイルを読み込んだりするたびに、その内容を内部の BTreeMap<u16, String> に溜めていく仕組みです。

そして #save は、このプログラムバッファの中身をローカルのファイルに書き出すコマンドでした。

pub fn save_program(&self, command_line:&str) {
    let tokens: Vec<&str> = command_line.split(' ').collect();
    let path_str = tokens[1];
    let path = PathBuf::from(path_str.trim_matches('\"'));
    let file = File::create(path).expect("Failed to create file");
    let mut writer = BufWriter::new(file);
    for (line_number, program) in self.prog_buff.iter() {
        let line = format!("{} {}\n", line_number, program);
        writer.write_all(line.as_bytes()).expect("Failed to write to file");
    }
    writer.flush().expect("Failed to flush buffer");
}

一見するとごく普通の実装に見えるのですが、実際に触ってみると2つの大きな問題が浮かび上がってきました。


問題1. 保存に失敗すると msxterm ごと落ちる

存在しないディレクトリを指定して #save /nonexistent_dir/x.bas を実行すると、File::create(path).expect(...) がそのまま panic してしまい、msxterm プロセスごと終了してしまうのです。当然、MSX0 との TCP 接続もそこでプツリと切れてしまいます……。

ファイル書き込みのような失敗しうる操作を .expect() で処理してしまうと、コマンドラインでの入力ミス程度でアプリ全体がクラッシュしてしまいます。これは流石に不親切ですよね。write_allflush も同様に .expect() されていたため、ディスクの空き容量不足や権限エラーでも同じ現象が発生してしまいます。

さらに詳しく調べると、もうひとつ地味に厄介な挙動を見つけました。プログラムバッファが空の状態で #save を実行すると、既存のファイルを空の内容で上書きしてしまうのです。バッファが空でも File::create はそのまま実行され、ループが1回も回らないまま「保存成功」扱いになってしまいます。うっかり空のバッファで #save してしまうと、大切な既存ファイルが空っぽになって消し飛んでしまうリスクがありました。


問題2. プログラムバッファは MSX0 の「今の状態」と一致するとは限らない

こちらは panic とは別の、もっと根本的な問題です。プログラムバッファに保持されるのは、

  • ターミナルから行番号付きで直接入力した行
  • #load でファイルから読み込んだ行

のみです。つまり、MSX0 側で直接入力・実行・編集されたプログラムはプログラムバッファに一切反映されません

たとえば、MSX0 の BASIC で直接プログラムを打ち込んだ場合や、以前のセッションから msxterm を再起動せずに使い続けている場合など、「ターミナル側が把握しているプログラム」と「MSX0 が実際に持っているプログラム」がズレてしまう状況は普通に起こり得ます。

その状態で #save を実行すると、ズレた(古い、あるいは空の)プログラムバッファの内容がそのままファイルに保存されてしまい、「保存したはずなのに中身が違う!」という事態に陥ってしまいます。

公式の README を確認してみると、この問題への対策として次のように記載されていました。

  • MSX0 側の内容とズレている場合があります。
  • その場合、Reload で MSX0 側と同期をとります。(未実装)

つまり「MSX0 側から現在のプログラムを取り込み直す機能(#reload_from)」は最初から構想されていたものの、実装が追いついていなかったようです。PRが提出されているのも確認したのですが、#save の信頼性を高めるためには、この #reload_from を新たに実装する方がよさそうだと判断しました。


対応1. save_program を panic しないように修正する

まずは、関数の戻り値を Result<usize, String> に変更し、すべてのエラーを呼び出し側に伝播させる形に書き換えました。あわせて、プログラムバッファが空の場合は書き込みを行わずにエラーを返すように改善しています。

pub fn save_program(&self, command_line: &str) -> std::result::Result<usize, String> {
    let path = parse_path_arg(command_line).ok_or("Usage: #save <file>")?;
    if self.prog_buff.is_empty() {
        return Err("Program buffer is empty. Nothing saved. (use #reload_from to fetch the program from MSX0)".to_string());
    }
    let file = File::create(&path).map_err(|e| format!("{}: {}", path, e))?;
    let mut writer = BufWriter::new(file);
    for (line_number, program) in self.prog_buff.iter() {
        let line = format!("{} {}\n", line_number, program);
        writer.write_all(line.as_bytes()).map_err(|e| format!("{}: {}", path, e))?;
    }
    writer.flush().map_err(|e| format!("{}: {}", path, e))?;
    Ok(self.prog_buff.len())
}

呼び出し側(メインループの #save 処理)ではエラーメッセージを表示するにとどめ、continue で入力ループを継続するようにしました。

if line.starts_with("#save") {
    match msxterm.save_program(line) {
        Ok(n) => println!("Ok ({} lines saved)", n),
        Err(e) => println!("{}", e),
    }
    continue;
}

なお、ファイルパスの引数パース(ダブルクォート処理)は #load と共通化し、parse_path_arg() という関数に切り出して両方のコマンドで使い回すように設計しています。


対応2. #reload_from の実装

#reload_from の役割は、「ターミナル側のプログラムバッファを破棄し、MSX0 に現在入っているプログラムをそのまま取り込み直すこと」です。

MSX0 の BASIC には list コマンドがあり、これを実行すると行番号付きの全プログラムがテキストとして出力されます。この出力を受信スレッド側で横取りしてバッファに溜め、Ok プロンプト(BASIC のコマンド完了の合図)が返ってきたら完了とするのが基本の流れです。

msxterm では受信専用スレッドが常時 TCP からデータを受信し、通常時はそのままターミナルに出力しています。#reload_from の実行中のみこの出力を横取りする必要があったため、メインスレッドと受信スレッドの間で同期をとる共有領域を用意しました。

// #reload_from 用: 受信スレッドが list の出力を取り込むための共有領域
struct Capture {
    lines: Vec<String>,
    done: bool,
    last_rx: Instant,
}
type SharedCapture = Arc<Mutex<Option<Capture>>>;

fetch_program()list を送信して Capture をセットし、受信スレッド側は「Capture がセットされている間は通常表示を止め、行番号で始まる行をバッファに追加し、Ok を検知したら完了フラグを立てる」という形で連携させています。

fn fetch_program(stream: &mut TcpStream, capture: &SharedCapture) -> std::result::Result<Vec<String>, String> {
    *capture.lock().unwrap() = Some(Capture { lines: Vec::new(), done: false, last_rx: Instant::now() });
    if let Err(e) = stream.write_all(&[b'l', b'i', b's', b't', C_CR as u8]) {
        capture.lock().unwrap().take();
        return Err(format!("Failed to write to server: {}", e));
    }
    let start = Instant::now();
    let timed_out = loop {
        thread::sleep(Duration::from_millis(20));
        let guard = capture.lock().unwrap();
        let cap = guard.as_ref().unwrap();
        if cap.done {
            break false;
        }
        if cap.lines.is_empty() && start.elapsed() > Duration::from_secs(5) {
            break true;
        }
        if !cap.lines.is_empty() && cap.last_rx.elapsed() > Duration::from_secs(1) {
            break false;
        }
    };
    let cap = capture.lock().unwrap().take().unwrap();
    if timed_out {
        return Err("No response from MSX0 (timeout). Program buffer is unchanged.".to_string());
    }
    Ok(cap.lines)
}

タイムアウト周りの処理は、実機を触りながら次のように調整しました。

  • 何も受信しないまま 5 秒経過した場合、MSX0 が無応答と判断してタイムアウト扱いにする。
  • 1行以上受信した後は、最後の受信から 1 秒間何も来なければ「list の出力は完了した」とみなして打ち切る(Ok の検知漏れに対する保険)。
  • タイムアウトした場合は既存のプログラムバッファを一切変更しない(中途半端に一部だけ書き換わるくらいなら、何もしない方が安全という判断です)。

コマンドハンドラ側ではこれを呼び出し、成功したらバッファを差し替えるだけのシンプルな処理にまとめました。

if line.starts_with("#reload_from") {
    match fetch_program(&mut stream, &capture) {
        Ok(lines) => {
            msxterm.prog_buff.clear();
            for l in &lines {
                msxterm.parse_basic(l);
            }
            println!("Ok ({} lines loaded from MSX0)", msxterm.prog_buff.len());
        },
        Err(e) => println!("{}", e),
    }
    continue;
}

実機での検証

Haiku 上で再ビルドし、実際の MSX0 に接続して動作検証を行いました。

> #save
Usage: #save <file>
> 10 PRINT "HELLO FROM HAIKU"
> #save /nonexistent_dir_xyz/x.bas
/nonexistent_dir_xyz/x.bas: No such file or directory (os error -2147459069)
> #save test_save_haiku.bas
Ok (1 lines saved)
> #new
Program Buffer is cleared.
> #reload_from
Ok (4 lines loaded from MSX0)
> #list
10 PRINT "HELLO FROM HAIKU"
20 I = I + 1
30 PRINT I
40 NEXT
> #quit
Tcp disconnecthistory save to history.txt

検証のポイントは以下の通りです。

  • 引数なしの #save → 使い方メッセージを出力して正常継続(panic なし)
  • 存在しないディレクトリへの #save(バッファあり)→ OS のエラーを表示して正常継続(panic なし)
  • #new でバッファを空にした後 #reload_from を実行 → MSX0 側に残っていた4行(以前の検証ログ含む)を list 経由で正しく取得!
  • 最後の #quit も、前回の記事で修正した TCP 切断や履歴保存を含めて問題なし(デグレなし)

RUST_BACKTRACE=1 を付けた状態で stderr も確認しましたが、クラッシュや panic の痕跡は一切ありませんでした。また、cargo test によるユニットテスト(parse_path_arg のクォート処理や save_program のエラーパスなど)13件もすべて通過しています。


対応内容のまとめ

今回対応した内容の一覧です。

症状原因対応
保存に失敗すると msxterm ごと落ちるFile::createwrite_all 等を .expect() で握りつぶしていたsave_program()Result を返す形に変更し、エラーメッセージ表示のみで安全に継続するように修正
バッファが空の状態で保存すると既存ファイルが空で上書きされる空バッファのチェックが存在しなかった空の場合は書き込み自体を行わずエラーを返す仕様に変更
#save の内容が MSX0 の実態とズレることがあるプログラムバッファはターミナル側で入力・ロードした内容しか保持しない設計だったため#reload_from を実装し、MSX0 に list を送信して最新のプログラムを取り込めるようにした

単に「バグを直す」だけでなく、#save の構造上の限界(プログラムバッファ=ターミナル側の認識でしかない)まで踏み込んで #reload_from を実装したことで、「保存前に一度 MSX0 の最新状態と同期する」という安全で正しい運用ができるようになりました!

今回の修正内容はすべて taoman26/msxterm にマージ済みです。

前回の記事を参考にビルドしてくださった方の環境にはすでに今回の修正も含まれていますので、安心してご使用ください。

また、Windows や macOS、Linux で本家の msxterm をお使いの方も、今回のリポジトリからビルドしていただければ #reload_from が利用できるようになります。気になった方はぜひ試してみてください!


今回のような技術検証、「うちの環境ではどうなんだろう?」と気になった方はいませんか。

ビューローみかみでは、構想段階の壁打ちからPoC・実装・現場導入まで、現場で「使い続けられる」ものづくりを支援しています。 

技術顧問サービスでは、本記事のような技術的な質問・検証にも継続的にお答えしています。「相談したら契約」ということはありません。システムを作らない判断も含めて、率直にお話しします。

▶ 技術顧問サービスの詳細はこちら

気になることがあればお気軽にご相談ください。

本日も最後までお読みいただきありがとうございました。

それではみなさん、よい Haiku ライフを!

カテゴリ: IoT

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール