LSP 実装メモ (Text Document Synchronization `textDocument/didSave` 編)
前回
引き続き、Text Document Synchronization 周りの仕様について書いていく。
textDocument/didOpen
通知 <- donetextDocument/didChange
通知 <- donetextDocument/willSave
通知 <- donetextDocument/willSaveWaitUntil
リクエスト <- donetextDocument/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; }
includeText
が true
な場合はサーバはクライアントが DidSaveTextDocumentParams
の text
フィールドを埋めることを期待することを示す。
クライアントは自身がこのフィールドを埋める機能を実装しているかどうかはサーバに通知できないので、サーバは includeText
を true
にしたところで、クライアントが対応していない場合のハンドリングはしておかなければならないと考えられる。
サーバ実装例
を読んでいく
efm-langserver
ハンドラの実装は以下
ちゃんと params.Text
の中身を取り扱っていることが分かる。
ただし、initialize
レスポンスに SaveOptions
を渡していないため、SaveOptions
を無視して保存後のテキストを送り付けてくるクライアントがあった場合にのみ発火する処理である。
// プロトコル上 includeText
が false
ならクライアントは 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 の独自実装かなと考えている。
クライアント実装
サーバ側を読んでいるとクライアント実装で気になるところが出てきたのでちょっとだけ追う。
SaveOptions
のincludeText
によってパラメータのtext
フィールドを操作しているかどうか- パラメータの TextDocument を VSCode に追従して
VersionedTextDocumentIdentifier
で扱っているかどうか
読むのは vim-lsp である。
includeText
のハンドリング
こちらはちゃんと includeText
に応じて text
を埋めるか決めている。
textDocument
を VersionedTextDocumentIdentifier
として扱っているか
uri
しか埋めていないことから通常の(仕様通りなのでもちろんこれが正しい) TextDocumentIdentifier
として扱っている。
というわけで次回は Text Document Synchronization 最終回 textDocument/didClose
編予定。
-- 追記(2020/08/23) -- かいた
LSP 実装メモ (Text Document Synchronization `textDocument/willSave` `textDocument/willSaveWaitUntil` 編)
週刊 LSP 第三号
前回 gopls で見つけた Issue は無事 merge された
引き続き、Text Document Synchronization 周りの仕様について書いていく。
textDocument/didOpen
通知 <- donetextDocument/didChange
通知 <- donetextDocument/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 に適用する変更を表わすための型である。
range
の start
と end
が等しいときは newText
の挿入を意味し、newText
が空のときは range
内の文字列を削除することを示す。
TextEdit[]
は単純にこれをリストにしたものではあるが、リスト内の各 range
が被ってはならない、というプロトコル上の制約が追加される。
ただし range
の中の start
が等しいことは許容される。
何をしたいかと言うと、同じ TextDocument の同じ位置への複数回の挿入を許容したいのである。
なお textDocument/didChange
の contentChanges
フィールドも deprecated な rangeLength
を持っていることを除けばこれと同一に見えるが、TextEdit[]
はあくまでそれ全体で一つの変更を示しており、contentChanges
フィールドはリストの各要素が一つの変更を示しているという点で異なる。
// これは textDocument/didChange
でのバージョン情報の扱いと range
の被りについての制約が無いことからの推測である。
今回の textDocument/willSave
textDocument/willSaveWaitUntil
は vim-lsp にも実装されておらず、適当に他の言語のサーバをあたってみるも実装されてるやつは見当らずだったためここまで。
次回 textDocument/didSave
予定
-- 追記(2020/08/15) -- かいた
LSP 実装メモ (Text Document Synchronization `textDocument/didChange` 編)
週刊 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 仕様に従い、Microsoft は Microsoft/vscode-uri で実装を公開している。
TextDocumentIdentifier
はその DocumentUri
を保持しており、version 情報を付加する場合はそれを継承した VersionedTextDocumentIdentifier
を使う。
今回の textDocument/didChange
通知では関係ないが、version
が null
を取ることができる。
これは 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 では character
は UTF-16 である。
これが実際に扱われているかも実装を読む際に注目してみたい。
line
/character
双方 0-based で、character
が line
に指定された行の文字数を越えている場合は 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
が指定されると contentChanges
は range
が指定されて、今回の textDocument/didChange
で反映すべき変更が全てクライアントから送信される。
このときサーバはそのリストの順番通りに反映処理をしなければならない。
また通知自体もクライアントから複数送信されるが、サーバはこの通知の順序も正しく扱わなければならない。
None
は全く差分が送信されないことを示すがこれはどういうときに使うんだろうか?
なお vim-lsp の実装 を読むと None
のときは本当に何も送信しないようである。
ここは今回のサーバ実装の見所その2である。
サーバ実装例
今回も
を読んでいく
efm-langserver/sqls
ハンドラは以下の部分である。
双方 TextDocumentSyncKind
は Full
のみ対応している。
そのためクライアントからはテキストの全体がまるっと1つだけ送信されてくる前提のコードになっている。
// 余談ではあるが、プロトコル上 TextDocumentSyncKind
が Full
のときに 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.
が含まれるコメント行の有無 で判定している。
// これ、何で定められていたんだっけな? コメントにも根拠となるリンクが貼られていたが、別に言語仕様として定められている訳ではなさそうである。
では、変更の扱いについて見ていく。
TextDocumentSyncKind
は Incremental
対応 なのでその辺りの処理も見れるはずである。
ここから先でリクエストパラメータから最終的な変更内容を計算している。
一応仕様上は Incremental
対応のサーバが Full
もサポートしなければならないということはなさそうだが、gopls ではサポートしている。
// やはりこちらも Full
で送られてくるときにはリストの長さは1である前提のようである。
Incremental
なパラメータは以下で計算される。
Incremental
な場合は UTF-16 で定められた Range
をいい感じに扱っているかどうかが見所と言っていたが、結論としては gopls はちゃんと扱っていそうである。
Go の内部的な位置情報は span と呼ばれており、それと LSP の表現を変換する ColumnMapper
という型が用意されているようである。
この辺も後日別枠で追っていこうと思う。
今回は ここ でちゃんと変換されてそうだなぁ、くらいで濁しておく。
次回 textDocument/didClose
編予定
-- 追記(2020/08/09) -- かいた
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 では Vim の filetype
値が送信される。
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 を変更しようとした
- Migrate from one IP pool to another の手順に従って(ここで手順の逸脱をしてしまっていたかもしれない) 移行先 IPPool を作成し、既存 IPPool で起動している Pod を削除した
- このとき
calico-kube-controllers
の Pod がCrashLoopBackOff
状態に入ってしまい、kubectl describe
を読んだところ、kube-apiserver への疎通が無くなってしまっているようだった - 元に戻すための試行錯誤をした後、あきらめてクラスタを作成しなおそうと、
kubeadm reset
を各 Node で実行した kubeadm reset
の後、ちゃんと標準出力には "iptables 削除しろよ!" "/etc/cni/net.d
削除しろよ!" って書いていたのだが、そのときの私はそれをよく読んでおらず、再度kubeadm init
からクラスタを構築した
結果、先に書いた状態に陥ってしまったの状態がきっかけであったのだが、その状態に陥った原因(恐らく 1. で手順の逸脱をしたのだろうが、何をやったかは覚えていない)は分からずじまいである
が、さらに完全に Pod への通信が不通になってしまってからの回復方法は結論
に書いたとおりである
(おまけ) Kubernetes the hard way 編
をひとしきりやってみて(後述の理由により、全部ではないが...)、ハマったところも書き残しておく
GCP アカウント作成直後の Quota に引っ掛り、必要な数の VM を立てることができない
結論
数日かけてのんびりやる
試行錯誤
Kubernetes the hard way は GCP 上で実行することを前提としており、私もそれに従って進めていた
構成は 3-master/3-worker で VM は全部で 6個必要であった
しかし、GCP アカウント作成直後は internal な IP アドレスが 4 つまでしか使用することができないようになっており、最後の 2 つの VM 作成に失敗してしまう
// 制限は "IAM と管理" > "割り当て" 内で確認できる
この制限を解除するための操作をしたところ、この操作はサポートへ直接お願いするという操作だったようで、サポートから "課金アカウント作成から 48h 待って作業してくれ" と言われてしまったので結論としてはゆっくり時間をかけて進めていく、ということになりそう
幸いにも worker 1 台分は確保できていたので、手順を worker 1 台で読み換えてそのまま続行することができた
という感じで、私のように書いてあることを全然読まずに変なハマり方をする人に届けば幸い
// 半分くらいは元も子も無いような結論ではあるが...
バイナリ操作奮闘記 前編(アセンブリ/手書き編)
最近以下のテキストを読みながら C コンパイラを書いています
書いてるうちにふと疑問がわいてきました
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
ret
は pop
してそのアドレスに飛ぶだけだから別にそれそのものが 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
ありがたいことに実際バイナリを触っているところのコードも公開されていますので参考にしていきます
というところで、永遠に書き終わらない気がしてきたので一旦中断
まだ readelf
の出力内容などは全然理解できてないけども、とりあえず色々いじってみる
後編へつづく(かも...)
Vim で自前バッファを用意するプラグインを書く
この記事は Vim Advent Calendar 2019 23 日目の記事です
最近メールを 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()
で最後に表示されていた改行の削除とバッファ設定の変更をしています
buftype
をnofile
にすることで:w
ができないようにしますbufhidden
をhide
にして: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
を利用したバッファ作成方法も解説したかったのですが、私自身がまだ理解しきれていないので今回はここまでです...
というわけで、自前でバッファを用意するプラグインを書くことができました