LSP 実装メモ (Text Document Synchronization `textDocument/didSave` 編)

前回

tennashi.hatenablog.com

引き続き、Text Document Synchronization 周りの仕様について書いていく。

  • textDocument/didOpen 通知 <- done
  • textDocument/didChange 通知 <- done
  • textDocument/willSave 通知 <- done
  • textDocument/willSaveWaitUntil リクエスト <- done
  • textDocument/didSave 通知 <- 今日ここ
  • textDocument/didClose 通知

textDocument/didSave 通知

前回の willSave willSaveWaitUntil は保存前だったが、textDocument/didSave 通知は保存後にクライアントから送信される。

interface DidSaveTextDocumentParams {
    textDocument: TextDocumentIdentifier;
    text?: string;
}

textDocument はおなじみ TextDocumentIdentifier つまり TextDocument の URI である。

text は保存された TextDocument の中身であるが、これを送信するかどうかは事前にクライアントと合意が取られる。

具体的にはクライアントから送信される intialize リクエストへのレスポンスの一部として以下が送信される。

export interface SaveOptions {
    includeText?: boolean;
}

includeTexttrue な場合はサーバはクライアントが DidSaveTextDocumentParamstext フィールドを埋めることを期待することを示す。
クライアントは自身がこのフィールドを埋める機能を実装しているかどうかはサーバに通知できないので、サーバは includeTexttrue にしたところで、クライアントが対応していない場合のハンドリングはしておかなければならないと考えられる。

サーバ実装例

を読んでいく

efm-langserver

ハンドラの実装は以下

ちゃんと params.Text の中身を取り扱っていることが分かる。
ただし、initialize レスポンスに SaveOptions を渡していないため、SaveOptions を無視して保存後のテキストを送り付けてくるクライアントがあった場合にのみ発火する処理である。
// プロトコルincludeTextfalse ならクライアントは text を埋めてはならない、とは書いていないためそのようなクライアントを書いてもよい、はず。

あらためて handleTextDocumentDidSave() を追っていく。
params.Text が埋まっている場合は updateFile() へ、そうでない場合は saveFile() へ処理が移る。
updateFile() の第三引数は TextDocument のバージョンを示すが、この textDocument/didSave で送信された text ではバージョンが変化しない(例え直前の textDocument/didChange の適用後から変更されていたとしても)ので nil を渡し、バージョンを変更しないことを伝えている。

saveFile() は linter を発火するための h.request チャネルに URI を伝えている。

efm-langserver は TextDocument の更新および保存の度に linter を動作させている、言い換えると textDocument/didChange textDocument/didSave 通知が送信されるたびにクライアントに textDocument/publishDiagnostics 通知を投げていることが分かる。

sqls

ハンドラの実装は以下

efm-langserver と同様に params.Text をハンドルしていることが分かる。
params.Text の中身に応じて updateFile()saveFile() に振り分けているところまでは同様だが、sqls は textDocument/publishDiagnostics に対応していないため、saveFile() では何もせず。updateFile()params.Text の内容を反映するのみである。

gopls

今回 gopls はハンドラ部分を軽く追うだけにしておく。
// これまでも別に深く追っていたわけではないが...

gopls では didModifyFiles() というメソッドが TextDocument 関連の通知の肝になる処理だが、これは gopls のキャッシュの持ち方と強く結び付いた処理なので、このあたり含め textDocument/didClose まで紹介した後に集中して読み解いておきたい。

gopls は source.FileModification という型のフィールドの内容で didModifyFiles() の処理を制御しており、params.Text の有無も同様に対応される。

ここまでを注意深く読んでいると、以下の部分がおかしいことに気付く。

LSP 3.15 で定められた仕様では TextDocument のバージョン情報は入っていないはずである。

実際の定義も VersionedTextDocumentIdentifier 型に変更されている。

gopls は LSP で定義された各種リクエスト/構造体を microsoft/vscode-languageserver-node から自動生成している(internal/lsp/protocol/typescript)。

で、実際 vscode-languageserver-node の定義を見れば

VersionedTextDocumentIdentifier になっている。
どうやら LSP 次期バージョンの 3.16 でも TextDocumentIdentifier のまま で、vscode-languageserver-node のこの実装は 4年前からある ようなので、VSCode の独自実装かなと考えている。

クライアント実装

サーバ側を読んでいるとクライアント実装で気になるところが出てきたのでちょっとだけ追う。

  • SaveOptionsincludeText によってパラメータの text フィールドを操作しているかどうか
  • パラメータの TextDocument を VSCode に追従して VersionedTextDocumentIdentifier で扱っているかどうか

読むのは vim-lsp である。

includeText のハンドリング

こちらはちゃんと includeText に応じて text を埋めるか決めている。

textDocumentVersionedTextDocumentIdentifier として扱っているか

uri しか埋めていないことから通常の(仕様通りなのでもちろんこれが正しい) TextDocumentIdentifier として扱っている。

というわけで次回は Text Document Synchronization 最終回 textDocument/didClose 編予定。

-- 追記(2020/08/23) -- かいた

tennashi.hatenablog.com

LSP 実装メモ (Text Document Synchronization `textDocument/willSave` `textDocument/willSaveWaitUntil` 編)

前回 tennashi.hatenablog.com

週刊 LSP 第三号

前回 gopls で見つけた Issue は無事 merge された

引き続き、Text Document Synchronization 周りの仕様について書いていく。

  • textDocument/didOpen 通知 <- done
  • textDocument/didChange 通知 <- done
  • textDocument/willSave 通知 <- 今日ここ
  • textDocument/willSaveWaitUntil リクエスト <- 今日ここ
  • textDocument/didSave 通知
  • textDocument/didClose 通知

前回は、次回 textDocument/didClose 予定と書いたが、やっぱり順番に見ていこうと思う。
残念ながらこれまで見てきたサーバ実装に willSave willSaveWaitUntil を実装したものが存在しないため、まとめて仕様の紹介のみとする。

textDocument/willSave 通知

この通知は TextDocument が保存される前にクライアントから送信される。

export interface WillSaveTextDocumentParams {
    textDocument: TextDocumentIdentifier;
    reason: number;
}

export namespace TextDocumentSaveReason {
    export const Manual = 1;
    export const AfterDelay = 2;
    export const FocusOut = 3;
}

TextDocumentSaveReason は保存をトリガした原因を示す。
Manual は手動で保存されたことを示し、AfterDelay は定期的に実行される自動保存によることを示し、FocusOut はエディタからフォーカスが移動したことによる自動保存を示す。

textDocument/willSaveWaitUntil リクエス

textDocument/willSave のリクエスト版である。
レスポンスに TextEdit[] を返すことで、クライアントが TextDocument を保存する前にサーバが指定した編集を加えることが出来る点で異なるのだが、その変更を正しくクライアントが適用したかは保証されない。

リクエストパラメータは textDocument/willSave と全く同じで、レスポンスの型は以下になる。

TextEdit[] | null

TextEdit 型は以下で定義される

interface TextEdit {
    range: Range;
    newText: string;
}

これはその名の通り TextDocument に適用する変更を表わすための型である。
rangestartend が等しいときは newText の挿入を意味し、newText が空のときは range 内の文字列を削除することを示す。
TextEdit[] は単純にこれをリストにしたものではあるが、リスト内の各 range が被ってはならない、というプロトコル上の制約が追加される。
ただし range の中の start が等しいことは許容される。
何をしたいかと言うと、同じ TextDocument の同じ位置への複数回の挿入を許容したいのである。 なお textDocument/didChangecontentChanges フィールドも deprecated な rangeLength を持っていることを除けばこれと同一に見えるが、TextEdit[] はあくまでそれ全体で一つの変更を示しており、contentChanges フィールドはリストの各要素が一つの変更を示しているという点で異なる。
// これは textDocument/didChange でのバージョン情報の扱いと range の被りについての制約が無いことからの推測である。

今回の textDocument/willSave textDocument/willSaveWaitUntilvim-lsp にも実装されておらず、適当に他の言語のサーバをあたってみるも実装されてるやつは見当らずだったためここまで。

次回 textDocument/didSave 予定

-- 追記(2020/08/15) -- かいた

tennashi.hatenablog.com

LSP 実装メモ (Text Document Synchronization `textDocument/didChange` 編)

tennashi.hatenablog.com

週刊 LSP 第二号。

Language Server Protocol に従ったサーバを実装するためのメモ

前回に引き続き、Text Document Synchronization 周りの仕様について書いていく。

  • textDocument/didOpen 通知
  • textDocument/didChange 通知 <- 今日ここ
  • textDocument/willSave 通知
  • textDocument/willSaveWaitUntil リクエス
  • textDocument/didSave 通知
  • textDocument/didClose 通知

textDocument/didChange 通知

textDocument/didChange 通知はクライアントでの TextDocument への変更をサーバに伝えるための通知である。

リクエストパラメータ...の前に今回使ういくつかの型について。

TextDocument の URI 関連

前回も一応 DocumentUri 型は使用していたが詳細について触れなかった。
ここで少し触れておこうと思う。

TextDocument は URI でサーバ上一意に識別される。
DocumentUri 型は URI として valid であるような文字列と定められている。
具体的には RFC3986 に定められた URI 仕様に従い、MicrosoftMicrosoft/vscode-uri で実装を公開している。

TextDocumentIdentifier はその DocumentUri を保持しており、version 情報を付加する場合はそれを継承した VersionedTextDocumentIdentifier を使う。

今回の textDocument/didChange 通知では関係ないが、versionnull を取ることができる。
これは textDocument/didOpen 通知を受信する前にサーバが特定の TextDocument に対して VersionedTextDocumentIdentifier を送信するというユースケースを想定したものである。
// サーバは textDocument/didOpen 通知で初めて TextDocument のバージョンを確定できるのであってそれ以前はバージョンは不明(ディスク内が真)なのである。

type DocumentUri = string;

interface TextDocumentIdentifier {
    uri: DocumentUri;
}

interface VersionedTextDocumentIdentifier extends TextDocumentIdentifier {
    version: number | null;
}

TextDocument 本文内の位置関連

textDocument/didChange 通知ではドキュメントの編集位置を示すために使われる。
残念ながら LSP では characterUTF-16 である。
これが実際に扱われているかも実装を読む際に注目してみたい。

line/character 双方 0-based で、characterline に指定された行の文字数を越えている場合は line の文字数に切り落とされる。

また -1 で最終行を表わす、というような規定は無い。

Range は始点/終点でドキュメント内の範囲を表わす。

interface Position {
    line: number;
    character: number;
}

interface Range {
    start: Position;
    end: Position;
}

というわけであらためて、textDocument/didChange 通知のパラメータを見ていく。

interface DidChangeTextDocumentParams {
    textDocument: VersionedTextDocumentIdentifier;
    contentChanges: TextDocumentContentChangeEvent[];
}

export type TextDocumentContentChangeEvent = {
    range: Range;
    rangeLength?: number;
    text: string;
} | {
    text: string;
}

textDocument は先に見た通りである。

contentChanges

TextDocumentContentChangeEvent 型は2パターンの型が存在する。
おおまかには range が存在するかどうかで、存在しない場合は text フィールドの値がその TextDocument 全体の内容であると判断される。
そうでなければ、TextDocument 内の range 内の内容が text フィールドの内容に置き変わると判断される。

どちらがクライアントから送信されるかは事前にクライアントと合意を取っておく。
今回、詳細は触れないが、以下の定数が用意されており、サーバが扱える手段についてクライアントに伝える。

export namespace TextDocumentSyncKind {
    export const None = 0;
    export const Full = 1;
    export const Incremental = 2;
}

Full が指定されると contentChanges の長さは 1 で、常に range フィールドが空の型でクライアントから送信される。
Incremental が指定されると contentChangesrange が指定されて、今回の textDocument/didChange で反映すべき変更が全てクライアントから送信される。
このときサーバはそのリストの順番通りに反映処理をしなければならない。
また通知自体もクライアントから複数送信されるが、サーバはこの通知の順序も正しく扱わなければならない。
None は全く差分が送信されないことを示すがこれはどういうときに使うんだろうか?

なお vim-lsp の実装 を読むと None のときは本当に何も送信しないようである。

ここは今回のサーバ実装の見所その2である。

サーバ実装例

今回も

を読んでいく

efm-langserver/sqls

ハンドラは以下の部分である。

双方 TextDocumentSyncKindFull のみ対応している。

そのためクライアントからはテキストの全体がまるっと1つだけ送信されてくる前提のコードになっている。
// 余談ではあるが、プロトコルTextDocumentSyncKindFull のときに TextDocument 全体を"一つだけ"送信しなければならないとは言っていないので、変更した TextDocument 全体を複数送信するクライアントが存在するかもしれないが、まぁ大抵の場合はクライアントが想定した挙動ではないはずである。

前回触れなかったが、efm-langserver は textDocument/publishDiagnostics 通知に対応しているが、sqls は対応していないという違いがある。
これが updateFile() の実装に表れている。

ハンドラの構造体には chan DocumentURI な型を持つ request というフィールドがあって、ここにリクエストパラメータ内の uri を放り込んでいる。

この chan に値が放り込まれると、

ここで受け取られて、指定された linter での処理が走り、その結果が textDocument/publishDiagnostics 通知としてクライアントに送信される。
具体的な linter の処理は textDocument/publishDagnostics の詳細を見るときまで置いておく。

gopls

ハンドラは以下

特徴的な部分として最後の処理から注目していきたい。

そのファイルを初めて変更し、かつそれが自動生成されたファイルの場合window/showMessage 通知を利用してそのことをクライアントに送信しようとしている。

gopls の Server 構造体は watchedFiles という map[span.URI]struct{} 型のフィールドを保持しており、ここで一度でも変更を加えたファイルを識別できるようにする予定のようである。
予定のようである、と書いたのは現状このフィールドに値を放り込んでいる処理が見当らないからである。

さらにこれも変な気がするのだが、_, ok := s.changedFiles[uri]ok をそのまま return してるので、changedFiles にあれば初めての更新だということになってしまっている。
これだと現状 wasFirstChange() は常に false が返るのではなかろうか。

-- 追記(2020/08/02) --
やっぱ変だなと思ったので Issue 上げといた。
-- 追記おわり --

なお、自動生成かどうかの判定は、

で行なわれる。
細かいことはさておき、DO NOT EDIT. が含まれるコメント行の有無 で判定している。
// これ、何で定められていたんだっけな? コメントにも根拠となるリンクが貼られていたが、別に言語仕様として定められている訳ではなさそうである。

では、変更の扱いについて見ていく。

TextDocumentSyncKindIncremental 対応 なのでその辺りの処理も見れるはずである。

ここから先でリクエストパラメータから最終的な変更内容を計算している。
一応仕様上は Incremental 対応のサーバが Full もサポートしなければならないということはなさそうだが、gopls ではサポートしている。
// やはりこちらも Full で送られてくるときにはリストの長さは1である前提のようである。

Incremental なパラメータは以下で計算される。

Incremental な場合は UTF-16 で定められた Range をいい感じに扱っているかどうかが見所と言っていたが、結論としては gopls はちゃんと扱っていそうである。
Go の内部的な位置情報は span と呼ばれており、それと LSP の表現を変換する ColumnMapper という型が用意されているようである。

この辺も後日別枠で追っていこうと思う。
今回は ここ でちゃんと変換されてそうだなぁ、くらいで濁しておく。

次回 textDocument/didClose 編予定

-- 追記(2020/08/09) -- かいた

tennashi.hatenablog.com

LSP 実装メモ (Text Document Synchronization `textDocument/didOpen` 編)

Language Server Protocol に従ったサーバを実装するためのメモ

まずは Text Document Synchronization の実装から調査する。

Text Document Synchronization とは

Text Document Synchronization は LSP サーバ/クライアント間で Text Document、つまり補完などの処理をする対象のテキストファイル内のデータを同期するための仕様群である。

LSP で定義されたこれに関連するメソッドは以下である。
// 今となってはどうでもいいが、ちょっと前まで仕様書には textDocument/open textDocument/change textDocument/close という未定義なメソッドについての記述があったが、さっき読みなおしたところ textDocument/didOpen textDocument/didChange textDocument/didClose に修正されていた。

  • textDocument/didOpen 通知
  • textDocument/didChange 通知
  • textDocument/willSave 通知
  • textDocument/willSaveWaitUntil リクエス
  • textDocument/didSave 通知
  • textDocument/didClose 通知

これらがクライアントから送信されてくる。

では各々のリクエストパラメータなどの詳細を見ていく。
短かく分けることで記事を継続して書くモチベーションになるかもしれないので、今回は textDocument/didOpen 通知のみを見ていく。

textDocument/didOpen 通知

textDocument/didOpen はドキュメントをクライアントが開いた際、そのことをサーバに教えるための通知である。

パラメータは以下である。
なお、仕様書が TypeScript で記述されているため、ここでもそのまま記述する。

interface DidOpenTextDocumentParams {
    textDocument: TextDocumentItem;
}

interface TextDocumentItem {
    uri: DocumentUri;
    languageId: string;
    version: number;
    text: string;
}

uri

LSP ではドキュメントの位置は URI で指定される。
クライアント側の実装例として prabirshrestha/vim-lsp を見ると、以下のように基本的には files://スキーマとして指定されてくるようである。
URI 生成処理部分

languageId

複数の言語に対応したサーバなど(例えば Go 言語の Language Server 実装である gopls は Go で書かれたコードだけでなく、go.mod の解釈もする)ではどの言語で書かれた TextDocument であるかは重要である。 このとき、URI をパースして拡張子を解釈したりしないですむように languageId を指定できる。

推奨される Language ID 一覧は TextDocumentItem の定義 に記述されている。
vim-lsp では Vimfiletype 値が送信される。

version

version はそのテキストのバージョンを示し、編集操作が行なわれる度増加(undo/redo などでも増加)する。
vim-lsp では 1 が送られる。

text

text にはファイルの中身のテキストが格納される。
uri が送信されるので、サーバ側で読めばいいのでは、と思われるかもしれないが、LSP ではテキストはクライアント側 (エディタ側) で管理されるものと定められており、プロトコルとしてサーバ側で開いてはならない (MUST NOT) と規定されている。
DidOpenTextDocument Notification

その他動作に関する仕様として、

  • textDocument/didOpen 通知は同一の TextDocument について連続で送信してはならない。
    • 必ず textDocument/didClose 通知を送信してから送信しなければならない。
    • 実際サーバ側が didOpen 通知を連続して受け取った場合にどのように処理すべきかについては触れられていない。
      • Note that a server’s ability to fulfill requests is independent of whether a text document is open or closed. という記述から、クライアントが送信してはならないがサーバは処理してもいい、と解釈できるかもしれない
      • このあたりはサーバ実装を調査する
  • languageId を変更する場合は TextDocument を開きなおさなければならない
    • つまり didClose 通知で閉じてから再度 didOpen 通知で開く
    • vim-lsp ではぱっと読んだところ FileType autocmd を処理してないので、途中で languageId が変更されても何も起きない、はず
    • 例えば JavaScript から TypeScript に Language ID を変更するときに、TypeScript に対応したサーバを起動すべきかどうかについては言及がない
      • 同様に JavaScript の TextDocument がなくなったときにサーバを終了すべきかどうかについても言及はない

サーバ側実装例

サーバ側でどのように実装しているかをいくつか観察してみる。
私は Go が一番手に馴染んでいるので、以下の Go で書かれたサーバ実装を見る。

efm-langserver/sqls

この二つの実装はだいたい同じ実装である。
シンプルにサーバ/ハンドラの構造体に Map でファイルの中身をそのまま持つ。

openFile() で map に登録して、updateFile() でパラメータのテキスト内容に更新する、という流れである。

またすでに開いている TextDocument のチェックは記述されていないため、didOpen が連続した場合は上書き登録される。

gopls

gopls は implementation.md にあるとおり、多重 cache 機構があるので、その兼ね合いで一見複雑そうに見えるが、基本的には同じでパラメータの URI にデータを登録して、テキスト内容に更新する。

まず Session.ViewOf() で登録された cache を探して、存在しなければ cache を初期化する。
その後、中身をパラメータで指定された値に更新する。

こちらもぱっと読んだ限り、すでに存在しているかのチェックは無いのでそのまま cache の内容が新しいパラメータで更新されるだけと思われる。

cache 周りの詳細な実装は追えておらず、また後日。

次は textDocument/didChange 編予定。

-- 追記(2020/08/01) --
書いた tennashi.hatenablog.com

ご家庭 k8s はじめました

ずっと電源すら入れてなかった Core i7 第4世代の PC、最近家で作業してるし置いとくのももったいなかったので k8s で遊ぶ用に整備した

ESXi 7.0 をインストールして、その上の仮想マシンで 1 master + 2 worker 構成で k8s を立てた
この構成の作成手順は無限にインターネットの海にただよっているので、私がはまって辛かったことを書き残しておく

ESXi 7.0 編

インストーラ付属の NIC ドライバではマザーボードNIC を認識できなかった

結論

Intel NIC 買うのが一番早い

試行錯誤

[ESXi no network adapters] でググると ESXi 6.7 での対処方法が大量に見つかる
方法は ESXi-Customizer-PS と PowerCLI をインストールして、使用する NIC のドライバをバンドルしたカスタムインストーラを作成するというものである
...が、所持していたマザーボードNIC(Killer network 製)のドライバが見付からず...(もしかしたら他のドライバで互換性のあるものがあったのかもしれないがそこまでは試していない)

UEFI モードでは起動できない

結論

Legacy モードで起動する

試行錯誤

まず、インストーラhttps://my.vmware.com/jp/web/vmware/evalcenter?p=free-esxi7 からダウンロードする
// バージョンが変わると死んでしまいそうな URL なので、一つ前のページも... https://www.vmware.com/jp/try-vmware.html ここの "無償製品のダウンロード" にあるリンクから辿る 購入した Inter NIC を使えば、ドライバ周りでハマることもなく、インストール完了まですんなりと辿りついた
で、再起動すると EFI shell が起動するのである... BIOS 画面から起動モードを UEFI から Legacy モードに変更して起動しなおすとブートローダを見付けることができ、起動に成功した

Kubernetes

やっとの思いで起動まで辿り付いた ESXi を操作して VM を適当に 3 つ立てる(なお 公式 によると CPU は 2 コア以上、メモリは 2GB が最低要件とのこと)

公式のインストール手順に従い、kubeadm で k8s クラスタを構築していく
CNI は Calico を選択した

Pod の IP アドレスへの疎通が無い

結論

kubeadm reset した後は

  • iptables の全テーブルちゃんと消す
  • /etc/cni/net.d を消す

試行錯誤

原因について明確なところは分かってないのだが、どこかのタイミングで iptables の状態が中途半端になってしまったため

  • 同一 Node の Pod 間(caliXXXXXXXX - caliYYYYYYYY)
  • IPIP の tunnel interface から Pod(tunl0 - caliXXXXXXXX)

に疎通が無い状態になってしまった
この状態になったときに行なっていた作業を書き残しておく

kubeadm での Kubernetes クラスタ構築がひとしきり完了した後、ふと Pod に割り振られている IP アドレスが気に入らなくなったので、Calico の IP Pool を変更しようとした

  1. Migrate from one IP pool to another の手順に従って(ここで手順の逸脱をしてしまっていたかもしれない) 移行先 IPPool を作成し、既存 IPPool で起動している Pod を削除した
  2. このとき calico-kube-controllers の Pod が CrashLoopBackOff 状態に入ってしまい、kubectl describe を読んだところ、kube-apiserver への疎通が無くなってしまっているようだった
  3. 元に戻すための試行錯誤をした後、あきらめてクラスタを作成しなおそうと、kubeadm reset を各 Node で実行した
  4. kubeadm reset の後、ちゃんと標準出力には "iptables 削除しろよ!" "/etc/cni/net.d 削除しろよ!" って書いていたのだが、そのときの私はそれをよく読んでおらず、再度 kubeadm init からクラスタを構築した
    結果、先に書いた状態に陥ってしまった

  5. の状態がきっかけであったのだが、その状態に陥った原因(恐らく 1. で手順の逸脱をしたのだろうが、何をやったかは覚えていない)は分からずじまいである
    が、さらに完全に Pod への通信が不通になってしまってからの回復方法は 結論 に書いたとおりである

(おまけ) Kubernetes the hard way 編

github.com

をひとしきりやってみて(後述の理由により、全部ではないが...)、ハマったところも書き残しておく

GCP アカウント作成直後の Quota に引っ掛り、必要な数の VM を立てることができない

結論

数日かけてのんびりやる

試行錯誤

Kubernetes the hard way は GCP 上で実行することを前提としており、私もそれに従って進めていた
構成は 3-master/3-worker で VM は全部で 6個必要であった

03-compute-resources

しかし、GCP アカウント作成直後は internal な IP アドレスが 4 つまでしか使用することができないようになっており、最後の 2 つの VM 作成に失敗してしまう
// 制限は "IAM と管理" > "割り当て" 内で確認できる
この制限を解除するための操作をしたところ、この操作はサポートへ直接お願いするという操作だったようで、サポートから "課金アカウント作成から 48h 待って作業してくれ" と言われてしまったので結論としてはゆっくり時間をかけて進めていく、ということになりそう
幸いにも worker 1 台分は確保できていたので、手順を worker 1 台で読み換えてそのまま続行することができた

という感じで、私のように書いてあることを全然読まずに変なハマり方をする人に届けば幸い
// 半分くらいは元も子も無いような結論ではあるが...

バイナリ操作奮闘記 前編(アセンブリ/手書き編)

最近以下のテキストを読みながら C コンパイラを書いています

www.sigbus.info

書いてるうちにふと疑問がわいてきました

9cc はアセンブリを出力しているけども実際の実行ファイルにするために gcc 挟んでいるし、アセンブリを ELF 形式の実行ファイルにするプロセスはどうすればいいんだ?
// そもそもテキストでも触れられていたのですが、気付いたのは試行錯誤の後...

というわけで、色々調べながら試行錯誤したのでそのログを残します

アセンブリ機械語は 1:1 で対応しているので変換表があれば手で変換できます
なのであとやるべきことは ELF 形式の実行ファイルの作り方さえ分かれば Linux 上で実行可能なファイルを作成できます

ELF 形式

そもそも ELF 形式、名前は知っているけども中身はよくわからんという状態だったので調べます

Linux kernel が実行ファイルを実行するために必要な情報をヘッダに入れて、実際の機械語は .text に入っています
ヘッダに何を指定すればいいのかを理解するためにはさらに色々調べる必要がありそうだったので、ヘッダは適当に何か簡単なアセンブリアセンブルして手に入れることにしました
ヘッダにはセクションの長さを保持する部分があるので、まずは機械語の長さは変えないようにバイナリエディタで書き換えてみます

最低限のアセンブリを用意する

とりあえずテキストで最初のほうにある

  • tmp.s
.intel_syntax noprefix
.global main
main:
        mov rax, 42
        ret

これを基にしていきます
これは実行すると exit code 42 が返ってくるだけのコードです

とりあえず、テキストでそれまでやっていたように gcc を使ってアセンブルします

$ gcc -o tmp tmp.s
$ ./tmp
$ echo $?
42

ELF 形式のヘッダを見てみます

$ readelf -a tmp
...
Symbol table '.symtab' contains 61 entries:
...
    26: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c
    27: 0000000000001070     0 FUNC    LOCAL  DEFAULT   13 deregister_tm_clones
    28: 00000000000010a0     0 FUNC    LOCAL  DEFAULT   13 register_tm_clones
    29: 00000000000010e0     0 FUNC    LOCAL  DEFAULT   13 __do_global_dtors_aux
    30: 0000000000004028     1 OBJECT  LOCAL  DEFAULT   24 completed.7447
    31: 0000000000003e20     0 OBJECT  LOCAL  DEFAULT   19 __do_global_dtors_aux_fin
    32: 0000000000001120     0 FUNC    LOCAL  DEFAULT   13 frame_dummy
    33: 0000000000003e18     0 OBJECT  LOCAL  DEFAULT   18 __frame_dummy_init_array_
    34: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS crtstuff.c
    35: 000000000000211c     0 OBJECT  LOCAL  DEFAULT   17 __FRAME_END__
    36: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS
    37: 0000000000003e20     0 NOTYPE  LOCAL  DEFAULT   18 __init_array_end
    38: 0000000000003e28     0 OBJECT  LOCAL  DEFAULT   20 _DYNAMIC
    39: 0000000000003e18     0 NOTYPE  LOCAL  DEFAULT   18 __init_array_start
    40: 0000000000002004     0 NOTYPE  LOCAL  DEFAULT   16 __GNU_EH_FRAME_HDR
    41: 0000000000004000     0 OBJECT  LOCAL  DEFAULT   22 _GLOBAL_OFFSET_TABLE_
    42: 0000000000001000     0 FUNC    LOCAL  DEFAULT   10 _init
    43: 0000000000001190     1 FUNC    GLOBAL DEFAULT   13 __libc_csu_fini
    44: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_deregisterTMCloneTab
    45: 0000000000004018     0 NOTYPE  WEAK   DEFAULT   23 data_start
    46: 0000000000004028     0 NOTYPE  GLOBAL DEFAULT   23 _edata
    47: 0000000000001194     0 FUNC    GLOBAL HIDDEN    14 _fini
    48: 0000000000000000     0 FUNC    GLOBAL DEFAULT  UND __libc_start_main@@GLIBC_
    49: 0000000000004018     0 NOTYPE  GLOBAL DEFAULT   23 __data_start
    50: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND __gmon_start__
    51: 0000000000004020     0 OBJECT  GLOBAL HIDDEN    23 __dso_handle
    52: 0000000000002000     4 OBJECT  GLOBAL DEFAULT   15 _IO_stdin_used
    53: 0000000000001130    93 FUNC    GLOBAL DEFAULT   13 __libc_csu_init
    54: 0000000000004030     0 NOTYPE  GLOBAL DEFAULT   24 _end
    55: 0000000000001040    43 FUNC    GLOBAL DEFAULT   13 _start
    56: 0000000000004028     0 NOTYPE  GLOBAL DEFAULT   24 __bss_start
    57: 0000000000001125     0 NOTYPE  GLOBAL DEFAULT   13 main
    58: 0000000000004028     0 OBJECT  GLOBAL HIDDEN    23 __TMC_END__
    59: 0000000000000000     0 NOTYPE  WEAK   DEFAULT  UND _ITM_registerTMCloneTable
    60: 0000000000000000     0 FUNC    WEAK   DEFAULT  UND __cxa_finalize@@GLIBC_2.2
...

ん? なんか libc の云々が呼ばれてない?
これでは該当の部分を抽出して操作し辛いので、他の方法を調べる

Gas(GNU assembler) を使う

C と関係ない純粋な(?)アセンブラに投げればいいのでは、と思ったので調べると GNU assembler というのがあったので使ってみる

$ as -o tmp tmp.s
$ chmod 755 tmp
$ ./tmp
Failed to execute process './tmp'. Reason:
exec: Exec format error
The file './tmp' is marked as an executable but could not be run by the operating system.

なるほど、これだけではだめなのか...
適当に使い方をググっている内に ld も使ってやらないといけなそうであることが分かった
// どこでその記述を見付けたのか忘れてしまった...

$ as -o tmp.o tmp.s
$ ld -o tmp tmp.o
$ ./tmp
Segmentation fault

なるほどわからん
とりあえずヘッダ見てみる

ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x401000
  Start of program headers:          64 (bytes into file)
  Start of section headers:          4336 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         2
  Size of section headers:           64 (bytes)
  Number of section headers:         5
  Section header string table index: 4

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000401000  00001000
       0000000000000008  0000000000000000  AX       0     0     1
  [ 2] .symtab           SYMTAB           0000000000000000  00001008
       00000000000000a8  0000000000000018           3     2     8
  [ 3] .strtab           STRTAB           0000000000000000  000010b0
       000000000000001e  0000000000000000           0     0     1
  [ 4] .shstrtab         STRTAB           0000000000000000  000010ce
       0000000000000021  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

There are no section groups in this file.

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000000b0 0x00000000000000b0  R      0x1000
  LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000
                 0x0000000000000008 0x0000000000000008  R E    0x1000

 Section to Segment mapping:
  Segment Sections...
   00
   01     .text

There is no dynamic section in this file.

There are no relocations in this file.

The decoding of unwind sections for machine type Advanced Micro Devices X86-64 is not currently supported.

Symbol table '.symtab' contains 7 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000401000     0 SECTION LOCAL  DEFAULT    1
     2: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _start
     3: 0000000000402000     0 NOTYPE  GLOBAL DEFAULT    1 __bss_start
     4: 0000000000401000     0 NOTYPE  GLOBAL DEFAULT    1 main
     5: 0000000000402000     0 NOTYPE  GLOBAL DEFAULT    1 _edata
     6: 0000000000402000     0 NOTYPE  GLOBAL DEFAULT    1 _end

No version information found in this file.

シンボルテーブルにまだ何か付いてるのは見えるけど、だいぶましになった
けど Segmentation fault になるのは何でかよくわからん

さらに調べていると以下の資料に行きついた

http://ankokudan.org/d/dl/pdf/pdf-linuxasm.pdf

retpop してそのアドレスに飛ぶだけだから別にそれそのものが exit code になる訳ではない
Linux kernel に exit code として伝えるためには exit システムコールを叩いてやらないといけなくて、gcc によるアセンブルで前後にくっついていた諸々はその辺りの処理もしてくれてたんだろうなぁ
という気付きを得たので、元のアセンブリを修正します

.intel_syntax noprefix
.global main

main:
    mov eax, 1
    mov ebx, 42
    int 0x80

先の資料では ebx に 0 を入れて正常終了と書いていたので、多分 ebx の値が exit code になるだろうということで入れてみました
というわけでアセンブルする

$ as -o tmp.o tmp.s
$ ld -o tmp tmp.o
$ ./tmp
$ echo $?
42

やったーできた
ヘッダも見ていきます

$ readelf -a tmp
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x401000
  Start of program headers:          64 (bytes into file)
  Start of section headers:          4344 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         2
  Size of section headers:           64 (bytes)
  Number of section headers:         5
  Section header string table index: 4

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000401000  00001000
       000000000000000c  0000000000000000  AX       0     0     1
  [ 2] .symtab           SYMTAB           0000000000000000  00001010
       00000000000000a8  0000000000000018           3     2     8
  [ 3] .strtab           STRTAB           0000000000000000  000010b8
       000000000000001e  0000000000000000           0     0     1
  [ 4] .shstrtab         STRTAB           0000000000000000  000010d6
       0000000000000021  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  l (large), p (processor specific)

There are no section groups in this file.

Program Headers:
  Type           Offset             VirtAddr           PhysAddr
                 FileSiz            MemSiz              Flags  Align
  LOAD           0x0000000000000000 0x0000000000400000 0x0000000000400000
                 0x00000000000000b0 0x00000000000000b0  R      0x1000
  LOAD           0x0000000000001000 0x0000000000401000 0x0000000000401000
                 0x000000000000000c 0x000000000000000c  R E    0x1000

 Section to Segment mapping:
  Segment Sections...
   00
   01     .text

There is no dynamic section in this file.

There are no relocations in this file.

The decoding of unwind sections for machine type Advanced Micro Devices X86-64 is not currently supported.

Symbol table '.symtab' contains 7 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000401000     0 SECTION LOCAL  DEFAULT    1
     2: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND _start
     3: 0000000000402000     0 NOTYPE  GLOBAL DEFAULT    1 __bss_start
     4: 0000000000401000     0 NOTYPE  GLOBAL DEFAULT    1 main
     5: 0000000000402000     0 NOTYPE  GLOBAL DEFAULT    1 _edata
     6: 0000000000402000     0 NOTYPE  GLOBAL DEFAULT    1 _end

No version information found in this file.

なんかよさそう
とりあえず、作ったバイナリを手でいじっていきます

.text セクションに機械語が入っているということは、このコード上の特徴的な数字である 42 (== 0x2a) があってかつその付近に != 0 なバイト列が固まっているところを探せばいいはずです
ここで著名なバイナリエディタである vim を開いて操作します

$ vim -b tmp
:%!xxd

バッファを xxd コマンドに食わせて人間が読めるように変換します

00001000: b801 0000 00bb 2a00 0000 cd80 0000 0000  ......*.........
00001010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00001020: 0000 0000 0000 0000 0000 0000 0300 0100  ................
...

ありましたね
とりあえず 0x2b に変更してみます

00001000: b801 0000 00bb 2b00 0000 cd80 0000 0000  ......*.........
00001010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00001020: 0000 0000 0000 0000 0000 0000 0300 0100  ................

あとはバイナリに書き戻します

:%!xxd -r

保存して再度実行してみます

$ ./tmp
$ echo $?
43

ちゃんと 0x2b (== 43) に変更されましたね

同じことを Go からやってみます

Go には encode/binary と debug/elf package があり、これらを使うことでバイナリを Go から編集できます
以下の資料を読めばどうやって触ればよいかが分かります

www.slideshare.net

github.com

ありがたいことに実際バイナリを触っているところのコードも公開されていますので参考にしていきます

というところで、永遠に書き終わらない気がしてきたので一旦中断
まだ readelf の出力内容などは全然理解できてないけども、とりあえず色々いじってみる

後編へつづく(かも...)

Vim で自前バッファを用意するプラグインを書く

この記事は Vim Advent Calendar 2019 23 日目の記事です

qiita.com

最近メールを vim で読むためのプラグインを書いています(道半ば)
機能としては

  • メールフォルダの一覧
  • フォルダの中のメール一覧
  • メール本文の表示

があるのですが、これらを vim 上で表示するためには自分でバッファを用意してやる必要があります
Vim は通常テキストエディタとして使用され、バッファに入っているテキストは何かのファイルに紐付いていることが多いと思います
しかし、今回のメールフォルダ一覧表示のためのバッファなどは特定のファイルに紐付いていません
今回はそのようなバッファをどうすれば用意できるのかについて解説します

なお、各コマンド/関数の詳細や厳密な挙動については触れていると分量が大変なことになると思うので、適宜 help へ解説を投げます

特定のコマンドを実行したらバッファを開く

ディレクトリ構成

testplugin/
  - plugin/
    - testplugin.vim

スクリプト

  • testplugin/plugin/testplugin.vim
if exists('g:loaded_testplugin')
  finish
endif
let g:loaded_testplugin = 1

let s:save_cpo = &cpo
set cpo&vim

command! TestPlugin split hoge

let &cpo = s:save_cpo
unlet s:save_cpo

解説

command! 行以外はおまじないです
二重にプラグインを読まないことや set cpoptions の上書きなどをしています
詳細は :h use-cpo-save を参照してください

command! 行では TestPlugin コマンドを定義して、split hoge を実行するようにしています
つまり、このプラグインを読み込んだ状態で :TestPlugin を実行すると :split hoge を実行したのと同様にウィンドウが横に分割され、hoge という名前のバッファが作成されます

作成したプラグインを読みこむ

ここで作成したプラグインを読みこむ方法はいくつかありますが、今回は雑に .vimrc に以下を追記して testplugin ディレクトリ内で vim を起動する、という方法を取ります

  • .vimrc
" for develop
set runtimepath+=$PWD

これで testplugin ディレクトリにいるときに vim を起動すると :TestPlugin が実行できるようになっているかと思います

作成したバッファにテキストを追加する

スクリプト

testplugin/plugin/testplugin.vim を変更していきます

  • testplugin/plugin/testplugin.vim
if exists('g:loaded_testplugin')
  finish
endif
let g:loaded_testplugin = 1

let s:save_cpo = &cpo
set cpo&vim

command! TestPlugin split hoge | call append(0, 'hoge')

let &cpo = s:save_cpo
unlet s:save_cpo

解説

:h :bar でコマンドを連結しています
ここでは :h append()hoge という文字列をバッファに追加しています

これで :TestPlugin を実行すると hoge という名前のバッファに hoge という文字列が追加されているかと思います

バッファの設定を変更する

ここまでで気付かれている方もおられると思いますが、開いた hoge という名前のバッファを :q で閉じようとすると、保存してないぞ、と怒られるかと思います
また :wq で抜けた場合 testvim ディレクトリに hoge という名前のファイルが作成されており、その状態のまま再度 vim を起動し :TestPlugin を実行すると hoge が二行表示されるかと思います

これは hoge というバッファが ./hoge というファイルに紐付いており、そのファイルが保存/更新されているのでこのような挙動になってしまいます

ここではそれを防ぎ、特定のファイルに紐付かないようにバッファの設定を変更していきます
ついでに処理が長くなってきたので関数に分離していきます

ディレクトリ構成

testplugin/
  - plugin/
    - testplugin.vim
  - autoload/
    - testplugin.vim

スクリプト

  • testplugin/plugin/testplugin.vim
if exists('g:loaded_testplugin')
  finish
endif
let g:loaded_testplugin = 1

let s:save_cpo = &cpo
set cpo&vim

command! TestPlugin call testplugin#open_buffer()

let &cpo = s:save_cpo
unlet s:save_cpo
  • testplugin/autoload/testplugin.vim
let s:save_cpo = &cpo
set cpo&vim

function! testplugin#open_buffer() abort
  split hoge
  call append(0, 'hoge')
  call deletebufline('%', '$')
  setlocal buftype=nofile
  setlocal bufhidden=hide
  setlocal noswapfile nobuflisted
endfunction

let &cpo = s:save_cpo
unlet s:save_cpo

(Updated(2019/12/23 16:54): command 行に call がない)

解説

autoload については :h autoload/:h 41.15 を参照してください
ここでは testplugin/autoload/testplugin.vim で定義された testplugin#open_buffer() が他から参照できるという点のみが重要です

:TestPlugin を実行するとこの testplugin#open_buffer() が呼ばれます
testplugin#open_buffer() ではこれまでの処理に加えて deletebufline() で最後に表示されていた改行の削除とバッファ設定の変更をしています

  • buftypenofile にすることで :w ができないようにします
  • bufhiddenhide にして :q から抜けたときにバッファを隠しバッファとします
  • noswapfile でこのバッファについて swap ファイルを作成しないようにします
  • nobuflisted でこのバッファを :ls などで見えないようにします

詳細については :h special-buffers などを参照してください
// 正直この辺どう設定したらいいかはまだ試行錯誤中なので他の人の知見も知りたいところです
// 例えば nomodifiable も設定したいけれど、設定すると :q してからもう一度 :TestPlugin するとエラーで怒られてしまうのでどうしよう? とか

ここまででファイルに紐付かないバッファを作るプラグインが出来ました
のでこれで終わり、と言いたいところですが、最後に少しだけ変更を加えます

バッファ名を変更する

今は :TestPlugin により開かれるバッファ名は hoge になっていますが、:TestPlugin を実行して :q してから :e hoge を実行すると分かるように、:e hoge により開かれる hoge というバッファは新規ファイルではなく、既存の :TestPlugin により開かれたバッファになります
これでは hoge という名前のファイルが作成できなくて困ってしまうので、プラグイン独自のバッファ名を付けましょう

  • testplugin/autoload/testplugin.vim
let s:save_cpo = &cpo
set cpo&vim

function! testplugin#open_buffer() abort
  split testplugin://hoge
  call append(0, 'hoge')
  call deletebufline('%', '$')
  setlocal buftype=nofile
  setlocal bufhidden=hide
  setlocal noswapfile nobuflisted
endfunction

let &cpo = s:save_cpo
unlet s:save_cpo

testplugin://hoge という名前に変更しました
被りさえしなければ何でもいいのですが、:h Cmd-event を利用してバッファを作成しているプラグインなど プラグイン名://バッファ名 のようにしているプラグインがいくつか見られるのでそれに合わせてみました
// Cmd-event を利用したバッファ作成方法も解説したかったのですが、私自身がまだ理解しきれていないので今回はここまでです...

というわけで、自前でバッファを用意するプラグインを書くことができました