Logo address

HTTP POST

2023/09/19

はじめに

最近のことだが、或る問題で HTTP の POST method を使う場面があったのであるが、動作に疑問を感じた。僕は HTTP サーバー Pegasus のコードを書き上げた当時は、 POST については、よく解っていたはずだが、最近は年のせいもあり、自信がない。そこでもう一度復習することとした。

HTTP POST と言うのはサーバーに対してデータを送るためのプロトコルである。通信プロトコルには Web で使われている HTTP が使われている。送るデータは、簡単なテキストから、画像ファイルのようなバイナリデータのファイルまで想定されている。以下では理解を確かなものにするために実験を行う。従ってクライアント側は HTTP 通信あるいは TCP 通信を行うソフトウェアを持つ必要がある。Web で使われるブラウザはそのようなソフトウェアの一つではあるが、行なっていることはブラックボックスに近いので、何だか変だなと感じたときに、追求しにくい。もっとシンプルなツールが欲しい。unix の世界には、TCP の通信ソフトとしては curl と nc (netcat) が名高い。これについては後で取り上げる。僕は Plan9 上に connect と名付けた通信ツールを作って持っている。シンプルの極みで、こういうのが一倍扱い易い。unix 上に Russ Cox の P9P (Plan9 Port) をインストールしていれば、そこに含まれている dial も良いであろう。

HTTP のバージョンは web 誕生以来、進化しているが、現在においても基本的なところは

で間に合っている。これは Pegasus のレベルである。
HTTP/0.9 については、公式のドキュメントを探すのは難しい。初期のプロトコルであり、本当に原始的である。GET method のみで POST はサポートされていない。従って以下の議論から外す。

用語

この記事の unix とは Linux, FreeBSD, MacOS に代表される unix 系 OS のことである。Plan9 は 9front で動作が確認されている。

実験環境

Hosts

以下の実験に関係するコンピュータは次の3つである。

name ip OS command prompt
hebe 192.168.0.6 Plan9(9front) hebe%
maia 192.168.0.3 Linux(Ubuntu) ubu$
mbook 192.168.0.249 MacOS mbook$

OS

実験で確認しているのは

である。

Browser

Browser を使った実験は MacBook から行っている。

name version
Safari 12.1.2
Chrome 103.0.5060.134
Firefox 115.0.3
僕の MacBook のソフトウェア環境は古いままである。OS は 10.12.6 (Sierra) のままである。理由はハードウェアが古くて、更新させて貰えないから。(しかし僕にとっては不自由は無いし、使えるものを捨てるのは嫌いである)

Listener

Listener と言うのは接続要求を待っているプロセスのことである。お客さんへの案内係と言ってもよい。
Listener は、或る port への接続要求がくると、port ごとに指定されたプログラムに仕事を任せる。port 番号(サービスの窓口番号と考えてよい)とプログラムとの関係は Plan9 の場合には簡単で、例えば tcp ポート 8083 への接続要求は、 /rc/bin/service の中に置かれた実行プログラム tcp8083 が処理する仕組みになっている。

unix では Listener として xinetd がよく使われる。しかし幾分複雑である。

xinetd が動いていることは次のように確認できる:

ubu$ ps ax|grep xinet
1147587 ?        Ss     0:00 /usr/sbin/xinetd -pidfile /run/xinetd.pid -stayalive -inetd_compat -inetd_ipv6
1158776 pts/3    S+     0:00 grep xinet
ubu$

xinetd を使ったサービスの提供は次の手順で行う:
1. port 8083 のサービス名称を xxx とせよ。xxx とポート番号との関係は /etc/services に書いておく:

xxx		8083/tcp	# for experiment
すでに非常に多くのサービスの名称が定義されているので、末尾に追加すればよい。

2. ファイル /etc/xinetd.d/xxx を作成して、その内容を

service xxx
{
        disable         = no
        socket_type     = stream
        protocol        = tcp
        wait            = no
        user            = root
        server          = path_xxx
}
とする。ここに path_xxx と書いたのはサービスを行うプログラムの path である。
他に多くの項目があるが、殆どの場合はこれでよいはずである。
僕の場合にはルールを決めて、xxxtcp8083 に固定して(つまり Plan9 のルールに合わせている)
service tcp8083
{
        disable         = no
        socket_type     = stream
        protocol        = tcp
        wait            = no
        user            = root
        server          = /usr/local/sbin/tcp8083
}
としている。過度な柔軟性は管理を面倒にするだけだから。

サービス・プログラム

Linstner によって起動されるサービスの書き方は(unix の場合も Plan9 の場合も)非常に簡単である。
サービス・プログラムへのリクエストは標準入力からやってくる。標準入力を読み取り、リクエストを吟味して回答を標準出力に書き出す。例えば Lua で書かれた次のプログラムはリクエストされた文字列に関する情報(文字列の長さと文字列自体)をクライアントにエコーバックする。そして終了したら "done" を書き出している。

#!/usr/bin/lua
g = io.stdin
print(os.date())
s = g:read("l")
while s and #s > 0 do
	print(#s,s)
	s = g:read("l")
end
print("done")

譜1: unix の tcp8083

補注: サービス・プログラムには実行フラグを立てておく:
chmod 775 tcp8083
程度が使いやすい。
最初の行の "#!/usr/bin/lua" の /usr/bin/lua は Lua の置き場所を表しており、OSに依存する。unix 系の OS では、この場所は
which lua
で判る。
同じ動作をするプログラムはもっと簡単に書ける。しかし、このプログラムを挙げたのは、後の議論との関係である。

譜1の

s = g:read("l")
g から1行を読み出し、結果を s に格納する。"l"(エル) は line を意味する。行末のコード('\n') は含まれない。"#s" は s のサイズである。空行では "#s" は 0 である。ファイルの末尾で読み取ると "s" は nil であり、その場合 #s はエラーとなる。
これは非常にうまい設計なのである。Plan9 の入出力関数ルーチン群 Bio に1行を読み取る関数 Brdline() があるが、読み取るべき行に、行末記号 ('\n') を含めてしまったがために、バグの原因となっており、現在では「使うな」扱いとなっている。

Lua に不慣れな読者のために、Lua の基本的な入出力を含むプログラムを載せておく。
http:a7.lua
使い方と動作はプログラム中にコメントされている。
a7.lua は Plain text なので、ブラウザにテキストとして表示されると期待しているのだが、
どうした訳か Safari だとバイナリデータと同じ扱いになり、ダウンロードされてしまう。
どうして Apple はこんな簡単な所を間違うのだろうね。Chrome や Firefox は期待通りの振る舞いをする。

以上のコメントを頭に入れて、まずは譜1の tcp8083 の動作を確認する。tcp8083 の置き場所に移動して

ubu$ tcp8083
Sat Sep 16 07:22:13 2023
abc
3	abc
defg
4	defg
done
ubu$

図1: tcp8083 の実行例

この場合、実行の終了は、普通のプログラムと同様に、 EOF (ctl-D) でプログラム側に伝えられる。
"ctl-D" が働いていることと "done" が表示されたことを確認する。

譜1の tcp8083 の設計目標は、通信ができていることを確認することにある。類似のサービスとして tcp7 があり、これは echo サービスとして /etc/service に登録されているはずである。この内容は Plan9 の場合には

#!/rc
/bin/cat
である。unix の場合は最初の行が #!/bin/sh に置き換わっているだけのはずである。
しかし tcp7echo はあまりにも素っ気ない。 tcp7 と違って tcp8083 はアクセスするとまず時刻を表示している。それから出力に余分な情報が含まれている。

telnet を使ってアクセスすると次のようになる:

ubu$ telnet 192.168.0.6 8083
Trying 192.168.0.6...
Connected to 192.168.0.6.
Escape character is '^]'.
Fri Sep 15 08:59:13 2023
abc
4	abc
defg
5	defg
...

図2: telnet による tcp8083 の実行例

図1と図2を比較してみると、後者の方が #s の値が1つ大きい。これは unix における行概念と tcp 通信おける行概念が異なることから発生している。unx ではテキストの行は "\n" で終わる。他方 tcp 通信では行は "\r\n" で終わる。unix あるいは unix like な OS の Lua は、行は"\n" で終わると考えている。そこへ行が "\r\n" で終わると考えるデータがやってきた結果を見ているのである。ここで述べたことは Plan9 で見ればよく判る:

Plan9 は制御コードを表示するフォントを持っているのである。

つまり譜1のプログラムは図1のように直接実行するのと、図2のように tcp 通信を通すのとでは動作が異なることが解る。直接実行では空行を入力すれば終わる。tcp 通信ではクライアントが空行を入力しても終わらない。この問題は重要なので後で改めて採り上げる。当面はサービス・プログラムの仕事に必要な能力をはっきりさせる。

サービス・プログラムが Listener によって起動されない場合にはプログラム作りは非常に難しくなる。httpd のように非常に頻繁にアクセスされるサービス・プログラムは Listener を介さないで動くようにできている。従って Listener が暗黙に行っていた仕事を自分で実行しなければならない。アクセス頻度の小さいサービスは Listener を通じた方が利口である。

Plan9 の場合には、サービスのために開かれている窓口は netstat コマンドで簡単に判る。unix でも昔は同様に netstat コマンドで判定できたのではないかと思える。しかし現在の unix の netstat コマンドでは、実行状態にあるサービスは判るが、潜在的な窓口は簡単には判らない。システム管理者にとっては憂慮すべき状態である。(何を売っているのか判断しにくい経営者のようなもの!)

tcp8083 にアクセス中のクライアントが存在していなければ、netstat コマンドは tcp8083 を表示しない。しかしこのポートはサービスされているのである。例えば mbook からアクセスすると

ubu$ netstat -t | grep 8083
tcp        0      0 maia:tcp8083            mbook.local:64930       ESTABLISHED
ubu$
と表示される。Plan9 の netstat は接客中の port だけではなく、開かれている port をすべて表示する。

kill -HUP

xinetd のサービスに変更が発生したら
sudo kill -HUP PID
を実行しなくてはならない。ここに PIDxinetd のプロセス ID である。
でなないと xinetd は変更を読みに行かない。Plan9 はこの必要はない。

実験

HTTP の通信プロトコルにおいて、クライアントがサーバーに送る最初のメッセージの内容を実験的に知ることがここでの目標である。以下で活躍するサービス・プログラム tcp8084 を次に示す。できるだけ簡単な方法でクライアントから送られたメッセージをファイルに記録している。

#!/bin/lua
-- look rfc1945 for HTTP/1.0
-- look rfc2616 for HTTP/1.1

f = io.open("/sys/log/tcp8084","a")
f:write("=== "..os.date().." ==\n")

-- the simplest one
s = io.stdin:read("a")
if s then
	f:write(s)
	print("HTTP/1.1 200 OK\r")	-- fake response
	print("Connection: close\r")	-- maybe required
end

譜2: tcp8084 (Plan9 版)

これは Plan9 のプログラムであるが、譜1の tcp8083 を参考にすれば unix に移植するのはもはや容易なはずである。
("/bin/lua" を "/usr/bin/lua" に、 "/sys/log/tcp8084" を "/var/log/tcp8040" に変更すればよい。)

譜1との大きな違いは、input をエコーバックしないで、そのままファイルに記録している点である。
HTTP 通信において、エコーバックはクライアントを混乱させる。
stdin をすべて読み終えたら何かやることになっているが、アクセスしたクライアントが動いていめ間は読み終えたことにはならない。人手によるクライアントの終了が要求される。このことは譜2の重大な欠陥である。サービス・プログラムが終了すればクライアントも自動終了するのだが、しかしどうやって...

HTTP GET

GET method は簡単なのでここから出発する。この中にも重要な論点が存在する。

mbook の Safari から http://192.168.0.6:8084/ にアクセス。
log/tcp8084

=== Fri Sep 15 14:33:34 2023 ==
GET / HTTP/1.1
Host: 192.168.0.6:8084
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Safari/605.1.15
Accept-Language: ja-jp
Accept-Encoding: gzip, deflate
Connection: keep-alive

図3 HTTP GET

もしも url を

http://192.168.0.6:8084/path?query
とすれば
GET /path?query HTTP/1.1
となったであろう。

他のブラウザでも大差はない。アクセスまでの手順を telnet で表せば、次のようなものである。

telnet 192.168.0.6 8084
GET / HTTP/1.1
Host: 192.168.0.6:8084
...

最後に空行が送られている。
GET に続く空行までの行は request header と言い、各行は

request ":" value
の形をとっている。これについては RFC に詳しい。

HTTP POST

POST を行ってくれるコマンドツールとして curl があるが後回しにする。
HTTP の世界で何と言ってもよく使われている、従ってまたバグが少ないのはブラウザーである。
以下では我が MacBook で動いている3つのブラウザー(Safari, Chrome, Firefox)を POST の実験ツールとして利用する。

以前に僕のサーバの中に、HTTP POST の実験ページを公開していた。このページは現在も残っている:

http://ar.nyx.link/test/post.html
このページは POST で送られる予定のコンテンツを表示してくれる。HTTP のヘッダー部は見えない。実際に送られたデータをすべて見たいのなら別の方法が必要である。

post.html のコードはブラウザの「ページのソース」を表示させれば手に入る。
ソースコードから飾りに関する部分を削除して、さらに "form action=" の値を

"http://192.168.0.6:8084/post/"
に置き換えたものを post2.html とする。

<!DOCTYPE html>
<html lang="ja"><head>
<meta charset="utf-8">
<title>POST Method Test</title>
</head>
<body>
<h1>POST Method Test</h1>
<h2 id="id.1.0.0">POST textarea</h2>
<code>enctype</code> の指定は無いが<br>
<div style="margin-left:2em;">
<pre>enctype="application/x-www-form-urlencoded"
</pre>
</div>
と結果は同じ<br>
<p>
Push the SUBMIT button<br>
<form action="http://192.168.0.6:8084/post/" method=POST target="other">
<input size=10 type="text" name="name" value="alice">
<input size=10 type="text" name="age" value="18">
<textarea name="DATA" rows="4" cols="40">
Please input
some data
</textarea>
<input type=submit value="SUBMIT">
</form>
<p>
<h2 id="id.2.0.0">POST textarea</h2>
<div style="margin-left:2em;">
<pre>enctype="application/x-www-form-urlencoded"
</pre>
</div>
Push the SUBMIT button<br>
<form action="http://192.168.0.6:8084/post/" method=POST enctype="application/x-www-form-urlencoded" target="other">
<input size=10 type="text" name="name" value="alice">
<input size=10 type="text" name="age" value="18">
<textarea name="DATA" rows="4" cols="40">
www-form-urlencoded
Please input
some data
</textarea>
<input type=submit value="SUBMIT">
</form>
<p>
<h2 id="id.3.0.0">POST file</h2>
<div style="margin-left:2em;">
<pre>enctype="multipart/form-data"
</pre>
</div>
<p>
Push the SUBMIT button<br>
<p>
<form enctype="multipart/form-data" method="POST" action="http://192.168.0.6:8084/post/" target="result">
File to send. Please brows. <input type="file" name="file" size="60"><br>
<input type=submit value="SUBMIT">
</form>
</body>
</html>

譜3 pot2.html

僕の場合には post2.html の置き場所を

/Users/arisawa/tmp/
として、ブラウザーの url 欄を
file:///Users/arisawa/tmp/post2.html
とすれば post2.html を表示してくれる。

このまま submit すると 192.168.0.6:8084 に POST してくれる。(POST を完了させるには人手の介入が必要で、止めてやらなくてはならない)
Safari の場合、結果は次のようになる。

=== Fri Sep 15 17:58:25 2023 ==
POST /post/ HTTP/1.1
Host: 192.168.0.6:8084
Content-Type: application/x-www-form-urlencoded
Origin: null
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Safari/605.1.15
Content-Length: 56
Accept-Language: ja-jp
Accept-Encoding: gzip, deflate

name=alice&age=18&DATA=Please+input%0D%0Asome+data%0D%0A
Content-Length: 56 となっている。この 56
「name=alice&age=18&DATA=Please+input%0D%0Asome+data%0D%0A」
のサイズである。つまり、これが POST における送信されたコンテンツである。

ファイルを POST する場合

を使う。送信ファイルを a3.txt として、その内容を "abc\ndefg\n" とする。結果は次のとおりになる。

=== Sat Sep 16 15:12:17 2023 ==
POST /post/ HTTP/1.1
Host: 192.168.0.6:8084
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryJgBjrLio8OOUkR8B
Origin: null
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.2 Safari/605.1.15
Content-Length: 189
Accept-Language: ja-jp
Accept-Encoding: gzip, deflate

------WebKitFormBoundaryJgBjrLio8OOUkR8B
Content-Disposition: form-data; name="file"; filename="a3.txt"
Content-Type: text/plain

abc
defg

------WebKitFormBoundaryJgBjrLio8OOUkR8B--

図4: ファイルの POST

Content-Length の 189 は
------WebKitFormBoundaryuqGiQCuwdnRuHGDm
...
------WebKitFormBoundaryuqGiQCuwdnRuHGDm--
の大きさで、各行末の "\r\n" も含む(ただしフアイルの内容の行末はそのまま)。(もともと binary ファイルの送信用に設計されているのだから当然)

HTTP POST では空行("\r\n")が重要な役割を果たしていることが解る。

curl

ここでは curl を使ったファイルの POST 実験を行う。
この実験は図4に対応している。

ubu$ curl -F file=@a3.txt http://192.168.0.6:8084
=== Sat Sep 16 18:43:12 2023 ==
POST / HTTP/1.1
Host: 192.168.0.6:8084
User-Agent: curl/7.81.0
Accept: */*
Content-Length: 193
Content-Type: multipart/form-data; boundary=------------------------00bdd4ee245bdf30

--------------------------00bdd4ee245bdf30
Content-Disposition: form-data; name="file"; filename="a3.txt"
Content-Type: text/plain

abc
defg

--------------------------00bdd4ee245bdf30--

診断法

通信に関係するソフトウェアを作るときに、正しく作られているかを確認できる環境が必要になる。診断ツールは可能な限りシンプルで、行われていることが簡単に理解されなくてはならない。

curl はうんざりする。オプションの山でやりたいことを見つけるにも苦労する。このようなソフトが好きな人もいるのだから不思議である。gnu のツールはこのような傾向がある。

偽装 HTTP POST サーバー tcp8080

譜2の tcp8084 は、POST したクライアントが自動停止しない問題点があった。次の譜4は、この問題点を克服する HTTP POST サーバーである。

#!/bin/lua
-- fake http server for post
-- look rfc1945 for HTTP/1.0
-- look rfc2616 for HTTP/1.1
substr = string.sub
match = string.match
find = string.find

function puts(f,s)
	f:write(s.."\n");
	if f == io.stdout then
		f:flush()
	end
end

function gets(g)
	s = g:read("l")
	if s == nil then
		return nil
	end
	r = find(s,"\r")
	if r then
		s = substr(s,1,r-1)
	end
	return s
end

conlen = nil
f = io.stdout
f = io.open("/sys/log/tcp8080","a")
puts(f,"=== "..os.date())
g = io.stdin
s = gets(g)
while s and #s > 0 do
	--print(s) -- DBG
	puts(f,s)
	--m = match(s,"^([%a-]+): *(%d+)")
	m = match(s,"^Content%-Length: *(%d+)")
	-- match returns only the first match (capture).
	-- if capture is absent, look manual.
	-- print("DBG m",type(m),m)
	if m then
		conlen = tonumber(m,10)
	end
	s = gets(g)
end
--print(conlen) -- DBG
if conlen then
	s = g:read(conlen)
	--print(s) -- DBG
	f:write(s)
	print("HTTP/1.1 200 OK\n")	-- need reasponse
end

譜4: tcp8080 (Plan9 版)

HTTP の規則では、ヘッダ部の終わりを示す空行と、空行以降の有効なデータのサイズを表す

Content-Length
が存在する。これが HTTP/1.0 の重要な工夫の一つである。譜4の tcp8080 ではこのことが利用されている。なお tcp8080 にはもう一つの工夫がある。ヘッダ部(次に続く空行も含む)の解析では行末は "\r\n" でも "\n" だけでも、どちらでも構わないようになっていることである。これによって unix や Plan9 クライアントから利用しやすくなっている。

この方法は HTTP/1.1(rfc2616) 推奨でもある:

The line terminator for message-header fields is the sequence CRLF.
However, we recommend that applications, when parsing such headers,
recognize a single LF as a line terminator and ignore the leading CR.

nc (netcat)

https://nc110.sourceforge.io

nc (netcat) は unix の通信ツールである。マニュアルを見る限り tcp や udp 通信の診断に使いやすい設計になっている。なかなか良さよう。

簡単な使い方の例

一つの window (A) で リスナーとして nc を起動する:

mbook$ nc -l 12345
-ll は listener を意味している。また 12345 はポート番号である。(適当に)

他の window (B) から 12345 にアクセスし何か適当にインプットする:

mbook$ nc localhost 12345
abc
すると window (A) には
abc
が表示されているはずである。

ここで行われたことは1つのコンピュータの中での実験であるが、もちろん相手が他のコンピュータでもよい。例えば maia8083 では譜1 の tcp8083 が動いているので

mbook$ nc -4 maia 8083
Sun Sep 17 14:51:02 2023
abc
3	abc
defg
4	defg
done
mbook$
ここに -4 は IPv4 接続の指定である。

同様に

mbook$ echo abc | nc -4 maia 8083
Sun Sep 17 19:31:44 2023
3	abc
done
mbook$
あるいは
mbook$ nc -4 maia 8083 <<EOF
> abc
> defg
> EOF
Sun Sep 17 19:35:19 2023
3	abc
4	defg
done
mbook$

残念なことにリスナーとして使えるようにしながら、プログラムを起動できる仕様にはなっていない。アクセスに応じてプログラムが起動されれば、使い方の幅が大きく広がったろうに...

nc は標準入力からのデータ入力をサポートしている。サポートしていると言うよりも、unix では普通にプログラムを書いていれば、そのようなる。その結果次のように、ホスト maia に対して curl からの POST であるかのように偽装できる。データ部が長くなるのでシェルスクリプトに書き込むが良い。

nc -4 maia 8080 <<EOF
POST / HTTP/1.1
Host: 192.168.0.6:8080
User-Agent: curl/7.81.0
Accept: */*
Content-Length: 193
Content-Type: multipart/form-data; boundary=------------------------00bdd4ee245bdf30

--------------------------00bdd4ee245bdf30
Content-Disposition: form-data; name="file"; filename="a3.txt"
Content-Type: text/plain

abc
defg

--------------------------00bdd4ee245bdf30--
EOF

nc は表立っては POST をサポートしていないが、実はなんとでもなるのである。nc の開発者は、こうしたこともよく考えて、使いやすいように設計していると思う。

dial

dial は P9P (Plan 9 Port) に含まれている Russ Cox の通信プログラムである。リスナーとしての機能はない。リスナーは既に P9P に含まれている。
彼は決してあれもこれもサポートしない。本当に必要な機能だけをサポートする。nc と同様な方法で POST もできる。

dial のコマンドシンタックスは

dial [-e] 'net!host!port'
である。nettcpudp を選択する。host は相手方の IP アドレスあるいは名前、port はボート番号あるいは名前である。実行環境が Plan9 の rc であれば引用符(') は不要である。
dial は 1つだけフラグを持っている。このフラグは POST のときに使用される。相手先が POST を受け入れるサーバーであり、正しい POST データを送信すれば、相手方が受信を完了し、接続を切断してくれる。これを待って dial は終了しなさいと言うのが "-e" フラグの趣旨である。

shell script を作ってその中に POST するデータを書くとよい。

tcp8083 に対してクライアントが自動終了するのは次のケース:

#!/bin/sh
dial -e 'tcp!hebe!8083' <<EOF
abc
defg

EOF

実行すると

Sun Sep 17 19:41:47 2023
3	abc
4	defg
done
が帰ってくる。これは OK

譜1を見れば判るように、tcp8083 の終了条件は、空行の読み取りか、あるいは read エラーである。後者は読み取るデータが存在しないときに、すなわち通常は EOF 条件で発生する。

どちらの条件で終了しているのかをはっきりさせるために、譜tcp8083 を少し改めて

#!/bin/lua
substr = string.sub
match = string.match
find = string.find

g = io.stdin
f = io.stdout
-- f = io.stderr -- regular case is comment out

function puts(f,s)
	f:write(s.."\n")
	if f == io.stdout then
		f:flush()
	end
end

function gets(g)
	s = g:read("l")
	if s == nil then
		return nil
	end
	r = find(s,"\r")
	if r then
		s = substr(s,1,r-1)
	end
	return s
end

print(os.date())
s = gets(g)
while s and #s > 0 do
	print(#s,s)
	s = gets(g)
end
if s then
	puts(f,"empty line")
else
	puts(f,"read error")
end
puts(f,"done")

譜5: tcp8085

を作って、空行によって終わったのか、それとも読み取りエラーで終わったのが判るようにする。ところが Russ の dial を使っている限り "read error" のメッセージを受け取ることはない。このことは "read error" で tcp8085 が終了しないことを意味しているのか?

サーバーがメッセージを送っても、そのときにクライアントが終了していたら、もちろんクライアントはメッセージを受け取らない。プログラムを読んだだけではモヤモヤするだけではっきりしない。

Plan9 には、この問題の解決に使える実験用にデザインされているリスナー listen1 がある。

ホスト hebe から

aux/listen1 tcp!*!8086 /rc/bin/service/tcp8085
を実行しておけば tcp8085 のプログラムが hebe8086 ポートにアクセスすることによって起動される。 tcp!*!8086 の中の * は、「クライアントを問わないよ」と言っている。

譜6の tcp8085f を(コメントを外して)

f = io.stderr -- regular case is comment out
としておく。

tcp8085 の標準エラー (io.stderr) への出力はクライアントには渡らないで、aux/listen1 を起動した hebe の window に表示される。従ってクライアントが終了しているか否かに関わらず、"empty line" で終了したのか、それとも "read error" で終了したのか決着が着く。

回線を切断すれば "read error" が発生する。fd を通信回線のファイル記述子だとすれば close(fd) で回線は切れる。クライアントプログラムが終了すれば OS によって自動的に close(fd) は実行される。Russ はこのことをよく知っているので、切断処理を OS に任せたのである。(従って、クライアントからは監察できない)

call

次の call.c は Russ の dial.c を少し修正したものである。
Plan9 には通信ツールとして con があるが、大きすぎる。そのために 僕は connect を作った。con を削りながら con にできないこともやってくれるので重宝している。
Russ も大きすぎる con を嫌ったのであろう。Russ は綺麗なコードを書く。僕の力任せの connect.c とは大違いである。比べると恥ずかしくなるくらいである。そこでこの際、Russ のを元に新たに作ったのである。
変更したのは
の2点である。-t フラグは、接続直前に終了するモードである。オープンポートの確認に使う。
通信のクライアントは、普通は2つのプロセスから構成される。一つは、キー入力を読み取り、それをサーバに送るプロセス A。もう一つは、サーバーからのデータを読み取り、それを画面に表示するプロセス B。
プロセス A は ctl-D で終了する。B も終了させるために、プロセス A は B にシグナルを送る。Russ のは KILL シグナル。これで B は終了する。ところがそれだとサーバーに送られたデータの処理が終わらないうちに B が終了し、困る。
この問題を Russ は -e フラグで処理した。サーバーの終了処理を待つフラグである。ところがサーバに送ったデータが、サーバーが終了する構造を持っていないと、-e フラグが機能しない。そのために相手待ちの終了しないプロセスが発生し得る。
call.c では、待ち時間のリミットを持たせることにした。もっとも(注意深く書かれた)サーバのプログラムは同様に alarm の下で動いているはずだから1 Russ のでも実際には間に合うけど...

#include <u.h>
#include <libc.h>
/*
 * "call" is a Plan9 version of "dial" in P9P by Russ Cox.
 * command named "dial" is alreay in 9front.
 * so I avoided the name in porting to 9front.
 *
 * "-t" option is added which was in connect.c
 * "-e" flag is discarded. instead "-a time" is introduced.
 *
 * compile: cc -o call call.c && cp call $home/bin/amd64
 * -Kenar
 */

int atime = 0; /* alarm time */
int tflag = 0;

void
usage(void)
{
	fprint(2, ("usage: call [-t] [-a time] addr\n"
	"-t: test mode\n"
	"-a time: alarm time to finish\n")
	);
	exits("usage");
}

void
killer(void *x, char *msg)
{
	USED(x);
	if(strcmp(msg, "kill") == 0)
		exits(0);
	noted(NDFLT);
}

void
main(int argc, char **argv)
{
	int fd, pid;
	char buf[8192];
	int n, waitforeof;
	char devdir[NETPATHLEN];
	char *lport = nil;
	char *dest;
	double ftime;

	notify(killer);
	waitforeof = 0;
	ARGBEGIN{
	case 'a':
		ftime = atof(EARGF(usage()));
		if(ftime > 0)
			atime = 1000*ftime; /* time in msec */
		break;
	case 't':
		tflag = 1; break;
	default:
		usage();
	}ARGEND

	if(argc != 1)
		usage();

	dest = argv[0];
	if((fd = dial(dest, lport, devdir, nil)) < 0)
		sysfatal("dial: %r");
	if(tflag){
		NetConnInfo *cinfo;
		fprint(2, "connected to %s on %s\n", dest, devdir);
		cinfo = getnetconninfo(nil, fd);
		fprint(2, "remote %s\n", cinfo->raddr);
		fprint(2, "local  %s\n", cinfo->laddr);
		exits(nil);
	}

	switch(pid = fork()){
	case -1:
		sysfatal("fork: %r");
	case 0:
		while((n = read(0, buf, sizeof buf)) > 0)
			if(write(fd, buf, n) < 0)
				break;
		if(1) /* for  experiment */
			close(fd); /* disconnect. so, make the server read error */
		if(!atime)
			postnote(PNPROC, getppid(), "kill");
		fprint(2,"child: exiting\n");
		exits(nil);
	}

	fprint(2,"parent: waiting\n");
	alarm(atime);
	while((n = read(fd, buf, sizeof buf)) > 0)
		if(write(1, buf, n) < 0)
			break;
	fprint(2,"parent: done\n");
	postnote(PNPROC, pid, "kill");
	waitpid();
	exits(0);
}

譜7: call.c

次の2つのメッセージはうざいなら削除してよい。

fprint(2,"child: exiting\n");
fprint(2,"parent: waiting\n");

call.c は P9P の下では次のようにすれば簡単にインストールできる。

9c call.c
9l -o call call.o
cp call $HOME/bin
しかしコンパイルするとエラーになる。Russ の dial.c と比較して、エラーの原因を除けばよい。

KILL シグナルは乱暴かも知れない。netcat はもう少し上品にやっているのではないかしら。僕はコードを読んでいないけど...

download:
http://p9.nyx.link/netlib/cmd/call/call-1.0.tgz


注1: インターネットに公開されるサービスでは、アラームタイマーは必須と考えたほうが無難である。aux/listen1 を使ってアラームタイマーの下でサービス prog を起動するには
aux/listen1 tcp!*!port alarm time prog
とすればよい。time は大抵は 1 (秒)で充分だろう。prog は絶対パスが要求される。
あるいは prog の方にアラームタイマーを仕込んであけば
aux/listen1 tcp!*!port prog
のままでよい。progrc スクリプトの場合には
#!/bin/rc
@{
sleep 1
ppid=`{cat /proc/$pid/ppid}
if(test -e /proc/$ppid)
	echo kill >/proc/$ppid/ctl
}&
echo zzz ...
"echo zzz ..." のところにプログラムを書く。この方法は aux/listen についても使える。
2023/09/23 追加