[インデックス 15293] ファイルの概要
このコミットは、Go言語の実験的なcookiejar
パッケージにおけるクッキーのソート順序を決定論的にし、それに関連するテストの信頼性を向上させるための変更を導入しています。特に、Windows環境でのテストの不安定性を解消し、システムクロックの操作や低解像度クロックを持つシステムでもクッキーの順序が予測可能になるように改善されています。
コミット
commit 6bbd12f1767b2b606c2a25981a1e74c21d8c67ef
Author: Volker Dobler <dr.volker.dobler@gmail.com>
Date: Mon Feb 18 11:27:41 2013 +1100
exp/cookiejar: make cookie sorting deterministic.
Re-enable TestUpdateAndDelete, TestExpiration, TestChromiumDomain and
TestChromiumDeletion on Windows.
Sorting of cookies with same path length and same creation
time is done by an additional seqNum field.
This makes the order in which cookies are returned in Cookies
deterministic, even if the system clock is manipulated or on
systems with a low-resolution clock.
The tests now use a synthetic time: This makes cookie testing
reliable in case of bogus system clocks and speeds up the
expiration tests.
R=nigeltao, alex.brainman, dave
CC=golang-dev
https://golang.org/cl/7323063
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/6bbd12f1767b2b606c2a25981a1e74c21d8c67ef
元コミット内容
このコミットの元のメッセージは以下の通りです。
exp/cookiejar
: クッキーのソートを決定論的にする。- Windows上で
TestUpdateAndDelete
、TestExpiration
、TestChromiumDomain
、TestChromiumDeletion
を再有効化する。 - 同じパス長と作成時間を持つクッキーのソートは、追加の
seqNum
フィールドによって行われる。これにより、システムクロックが操作されたり、低解像度クロックを持つシステムでも、Cookies
メソッドで返されるクッキーの順序が決定論的になる。 - テストは合成時間を使用するようになった。これにより、不正なシステムクロックの場合でもクッキーのテストが信頼できるようになり、有効期限テストが高速化される。
変更の背景
このコミットが行われた背景には、主に以下の2つの問題がありました。
- クッキーソートの非決定性:
exp/cookiejar
パッケージは、HTTPクッキーを管理するための機能を提供します。RFC 6265などの標準に従ってクッキーを処理する際、複数のクッキーが同じドメイン、パス、作成時間を持つ場合、それらのクッキーが返される順序がシステム環境(特にシステムクロックの解像度)によって非決定論的になる可能性がありました。これは、テストの再現性を損ない、デバッグを困難にする原因となります。特に、Windows環境ではこの問題が顕著であったようです(コミットメッセージのissue 4823
への言及から推測されます)。 - テストの不安定性: クッキーの有効期限や更新、削除に関するテストは、時間の経過に依存します。実際のシステムクロックを使用すると、テスト実行時の環境(特にクロックの精度や操作)によって結果が変動し、テストが不安定になることがありました。また、有効期限が切れるのを待つために
time.Sleep
のような処理が必要となり、テストの実行時間が長くなるという問題もありました。
これらの問題を解決し、cookiejar
パッケージの堅牢性とテストの信頼性を向上させることが、このコミットの主要な目的です。
前提知識の解説
このコミットを理解するために必要な前提知識は以下の通りです。
- HTTP Cookie: Webサイトがユーザーのブラウザに保存する小さなデータ片。セッション管理、パーソナライゼーション、トラッキングなどに使用されます。RFC 6265でその仕様が定義されています。
- Cookie Jar (クッキージャー): HTTPクライアントがクッキーを保存、管理、取得するためのメカニズム。Go言語の
net/http/cookiejar
パッケージ(このコミットでは実験的なexp/cookiejar
)がこれに該当します。 - 決定論的 (Deterministic): ある入力に対して常に同じ出力が生成される性質を持つこと。プログラムやアルゴリズムが決定論的である場合、その動作は予測可能であり、テストの再現性が高まります。クッキーのソート順序が決定論的であるとは、同じ条件のクッキーが常に同じ順序で返されることを意味します。
- 非決定論的 (Non-deterministic): ある入力に対して出力が毎回異なる可能性があること。システムクロックの解像度やスレッドの実行順序など、外部要因に依存する場合に発生しやすいです。
- 合成時間 (Synthetic Time): 実際のシステムクロックではなく、テストのためにプログラム的に制御された仮想的な時間。これにより、時間の経過に依存するテスト(例: 有効期限テスト)を、実際の時間を待つことなく、高速かつ再現性高く実行できます。
time.Time
: Go言語で時間を扱うための型。ナノ秒単位の精度を持つことができますが、システムによってはそれ以下の解像度しか提供されない場合があります。sort.Interface
: Go言語でスライスをソートするためのインターフェース。Len()
,Less(i, j int)
,Swap(i, j int)
の3つのメソッドを実装することで、sort.Sort
関数を使って任意の型のスライスをソートできます。このコミットでは、byPathLength
という型がこれを実装しています。eTLD+1
: "effective Top-Level Domain + 1" の略。パブリックサフィックスリスト(Public Suffix List, PSL)に基づいて、ドメインの登録可能な部分を特定するための概念。例えば、example.co.uk
の場合、co.uk
がパブリックサフィックスであり、example.co.uk
がeTLD+1
となります。クッキーのドメイン属性の検証に用いられます。
技術的詳細
このコミットの技術的な変更点は大きく分けて2つあります。
-
クッキーソートの決定論化:
Jar
構造体にnextSeqNum uint64
フィールドが追加されました。これは、新しく追加されるクッキーに一意のシーケンス番号を割り当てるためのカウンターです。entry
構造体(クッキーの内部表現)にseqNum uint64
フィールドが追加されました。このフィールドは、クッキーがJar
に追加される際にnextSeqNum
の値がコピーされます。byPathLength
型のLess
メソッド(クッキーをソートする際の比較ロジック)が変更されました。- 変更前は、パスの長さが同じクッキーの場合、作成時間(
Creation
)で比較していました。 - 変更後は、パスの長さが同じで、かつ作成時間も同じクッキーの場合に、新しく追加された
seqNum
フィールドで比較するようになりました。これにより、同じパス長と作成時間を持つクッキー間でのソート順序が、seqNum
によって一意に決定されるようになります。seqNum
は単調増加するため、常に同じ順序が保証されます。
- 変更前は、パスの長さが同じクッキーの場合、作成時間(
SetCookies
メソッド(現在は内部的にsetCookies
が呼ばれる)内で、新しいクッキーが追加される際にe.seqNum = j.nextSeqNum; j.nextSeqNum++
という行が追加され、シーケンス番号が割り当てられるようになりました。- 以前の
SetCookies
にあったnow = now.Add(1 * time.Nanosecond)
という、時間を強制的に進めることで決定論性を確保しようとしていたロジックは削除されました。これは、seqNum
の導入により不要になったためです。
-
テストにおける合成時間の導入:
jar.go
内のCookies
メソッドとSetCookies
メソッドが、それぞれ内部的にcookies(u *url.URL, now time.Time)
とsetCookies(u *url.URL, cookies []*http.Cookie, now time.Time)
という新しいメソッドを呼び出すように変更されました。これらの新しいメソッドは、現在の時刻をnow
パラメータとして受け取ります。- これにより、テストコードから
time.Now()
に依存せずに、任意の時刻をnow
として渡すことが可能になりました。 jar_test.go
では、tNow
というグローバル変数(time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC)
に初期化)が導入され、テスト内で使用される「現在の時間」として機能します。expiresIn
ヘルパー関数もtNow
を使用するように変更されました。jarTest.run
メソッド内で、クッキーの設定時や取得時に、tNow
を基にした合成時間がsetCookies
やcookies
メソッドに渡されるようになりました。これにより、テストの実行速度が向上し、システムクロックの変動に影響されなくなりました。- Windows環境でスキップされていた
TestUpdateAndDelete
,TestExpiration
,TestChromiumDomain
,TestChromiumDeletion
のt.Skip
呼び出しが削除され、これらのテストが再有効化されました。これは、ソートの決定論化と合成時間の導入により、これらのテストが安定して実行できるようになったためです。 newEntry
関数におけるクッキーの有効期限チェックロジックがif c.Expires.Before(now)
からif !c.Expires.After(now)
に変更されました。これは、Expires
がnow
と等しい場合(つまり、ちょうど有効期限が切れた瞬間)も期限切れとみなすようにするための、より厳密なチェックです。
これらの変更により、cookiejar
の動作がより予測可能になり、特にテスト環境での信頼性が大幅に向上しました。
コアとなるコードの変更箇所
src/pkg/exp/cookiejar/jar.go
--- a/src/pkg/exp/cookiejar/jar.go
+++ b/src/pkg/exp/cookiejar/jar.go
@@ -63,6 +63,10 @@ type Jar struct {
// entries is a set of entries, keyed by their eTLD+1 and subkeyed by
// their name/domain/path.
entries map[string]map[string]entry
+
+ // nextSeqNum is the next sequence number assigned to a new cookie
+ // created SetCookies.
+ nextSeqNum uint64
}
// New returns a new cookie jar. A nil *Options is equivalent to a zero
@@ -78,6 +82,11 @@ type entry struct {
Expires time.Time
Creation time.Time
LastAccess time.Time
+
+ // seqNum is a sequence number so that Cookies returns cookies in a
+ // deterministic order, even for cookies that have equal Path length and
+ // equal Creation time. This simplifies testing.
+ seqNum uint64
}
// Id returns the domain;path;name triple of e as an id.
@@ -135,11 +146,13 @@ type byPathLength []entry
func (s byPathLength) Len() int { return len(s) }
func (s byPathLength) Less(i, j int) bool {
- in, jn := len(s[i].Path), len(s[j].Path)
- if in == jn {
+ if len(s[i].Path) != len(s[j].Path) {
+ return len(s[i].Path) > len(s[j].Path)
+ }
+ if !s[i].Creation.Equal(s[j].Creation) {
return s[i].Creation.Before(s[j].Creation)
}
- return in > jn
+ return s[i].seqNum < s[j].seqNum
}
func (s byPathLength) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
@@ -148,6 +161,11 @@ func (s byPathLength) Swap(i, j) { s[i], s[j] = s[j], s[i] }
//
// It returns an empty slice if the URL's scheme is not HTTP or HTTPS.
func (j *Jar) Cookies(u *url.URL) (cookies []*http.Cookie) {
+ return j.cookies(u, time.Now())
+}
+
+// cookies is like Cookies but takes the current time as a parameter.
+func (j *Jar) cookies(u *url.URL, now time.Time) (cookies []*http.Cookie) {
if u.Scheme != "http" && u.Scheme != "https" {
return cookies
}
@@ -165,7 +183,6 @@ func (j *Jar) Cookies(u *url.URL) (cookies []*http.Cookie) {
return cookies
}
- now := time.Now()
https := u.Scheme == "https"
path := u.Path
if path == "" {
@@ -208,6 +225,11 @@ func (j *Jar) Cookies(u *url.URL) (cookies []*http.Cookie) {
//
// It does nothing if the URL's scheme is not HTTP or HTTPS.
func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) {
+ j.setCookies(u, cookies, time.Now())
+}
+
+// setCookies is like SetCookies but takes the current time as parameter.
+func (j *Jar) setCookies(u *url.URL, cookies []*http.Cookie, now time.Time) {
if len(cookies) == 0 {
return
}
@@ -225,7 +247,6 @@ func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) {
defer j.mu.Unlock()
submap := j.entries[key]
- now := time.Now()
modified := false
for _, cookie := range cookies {
@@ -249,16 +270,15 @@ func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) {
if old, ok := submap[id]; ok {
e.Creation = old.Creation
+ e.seqNum = old.seqNum
} else {
e.Creation = now
+ e.seqNum = j.nextSeqNum
+ j.nextSeqNum++
}
e.LastAccess = now
submap[id] = e
modified = true
- // Make Creation and LastAccess strictly monotonic forcing
- // deterministic behaviour during sorting.
- // TODO: check if this is conforming to RFC 6265.
- now = now.Add(1 * time.Nanosecond)
}
if modified {
@@ -384,7 +404,7 @@ func (j *Jar) newEntry(c *http.Cookie, now time.Time, defPath, host string) (e e
if c.Expires.Before(now) {
e.Expires = endOfTime
e.Persistent = false
} else {
- if c.Expires.Before(now) {
+ if !c.Expires.After(now) {
return e, true, nil
}
e.Expires = c.Expires
src/pkg/exp/cookiejar/jar_test.go
--- a/src/pkg/exp/cookiejar/jar_test.go
+++ b/src/pkg/exp/cookiejar/jar_test.go
@@ -14,6 +14,9 @@ import (
"time"
)
+// tNow is the synthetic current time used as now during testing.
+var tNow = time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC)
+
// testPSL implements PublicSuffixList with just two rules: "co.uk"
// and the default rule "*".
type testPSL struct{}
@@ -199,9 +202,9 @@ func TestDomainAndType(t *testing.T) {
}
}
-// expiresIn creates an expires attribute delta seconds from now.
+// expiresIn creates an expires attribute delta seconds from tNow.
func expiresIn(delta int) string {
- t := time.Now().Round(time.Second).Add(time.Duration(delta) * time.Second)
+ t := tNow.Add(time.Duration(delta) * time.Second)
return "expires=" + t.Format(time.RFC1123)
}
@@ -216,9 +219,12 @@ func mustParseURL(s string) *url.URL {
// jarTest encapsulates the following actions on a jar:
// 1. Perform SetCookies with fromURL and the cookies from setCookies.
+// (Done at time tNow + 0 ms.)
// 2. Check that the entries in the jar matches content.
+// (Done at time tNow + 1001 ms.)
// 3. For each query in tests: Check that Cookies with toURL yields the
// cookies in want.
+// (Query n done at tNow + (n+2)*1001 ms.)
type jarTest struct {
description string // The description of what this test is supposed to test
fromURL string // The full URL of the request from which Set-Cookie headers where received
@@ -235,6 +241,8 @@ type query struct {
// run runs the jarTest.
func (test jarTest) run(t *testing.T, jar *Jar) {
+ now := tNow
+
// Populate jar with cookies.
setCookies := make([]*http.Cookie, len(test.setCookies))
for i, cs := range test.setCookies {
@@ -244,11 +252,11 @@ func (test jarTest) run(t *testing.T, jar *Jar) {
}
setCookies[i] = cookies[0]
}
- jar.SetCookies(mustParseURL(test.fromURL), setCookies)
+ jar.setCookies(mustParseURL(test.fromURL), setCookies, now)
+ now = now.Add(1001 * time.Millisecond)
// Serialize non-expired entries in the form "name1=val1 name2=val2".
var cs []string
- now := time.Now().UTC()
for _, submap := range jar.entries {
for _, cookie := range submap {
if !cookie.Expires.After(now) {
@@ -268,8 +276,9 @@ func (test jarTest) run(t *testing.T, jar *Jar) {
// Test different calls to Cookies.
for i, query := range test.queries {
+ now = now.Add(1001 * time.Millisecond)
var s []string
- for _, c := range jar.Cookies(mustParseURL(query.toURL)) {
+ for _, c := range jar.cookies(mustParseURL(query.toURL), now) {
s = append(s, c.Name+"="+c.Value)
}
if got := strings.Join(s, " "); got != query.want {
@@ -588,7 +597,6 @@ var updateAndDeleteTests = [...]jarTest{
}
func TestUpdateAndDelete(t *testing.T) {
- t.Skip("test is broken on windows/386") // issue 4823
jar := newTestJar()
for _, test := range updateAndDeleteTests {
test.run(t, jar)
@@ -596,29 +604,26 @@ func TestUpdateAndDelete(t *testing.T) {
}
func TestExpiration(t *testing.T) {
- t.Skip("test is broken on windows/386") // issue 4823
jar := newTestJar()
jarTest{
- "Fill jar.",
+ "Expiration.",
"http://www.host.test",
[]string{
"a=1",
- "b=2; max-age=1", // should expire in 1 second
- "c=3; " + expiresIn(1), // should expire in 1 second
- "d=4; max-age=100",
+ "b=2; max-age=3",
+ "c=3; " + expiresIn(3),
+ "d=4; max-age=5",
+ "e=5; " + expiresIn(5),
+ "f=6; max-age=100",
},
- "a=1 b=2 c=3 d=4",
- []query{{"http://www.host.test", "a=1 b=2 c=3 d=4"}},
+ "a=1 b=2 c=3 d=4 e=5 f=6", // executed at t0 + 1001 ms
+ []query{
+ {"http://www.host.test", "a=1 b=2 c=3 d=4 e=5 f=6"}, // t0 + 2002 ms
+ {"http://www.host.test", "a=1 d=4 e=5 f=6"}, // t0 + 3003 ms
+ {"http://www.host.test", "a=1 d=4 e=5 f=6"}, // t0 + 4004 ms
+ {"http://www.host.test", "a=1 f=6"}, // t0 + 5005 ms
+ {"http://www.host.test", "a=1 f=6"}, // t0 + 6006 ms
+ },
}.run(t, jar)
-
- time.Sleep(1500 * time.Millisecond)
-
- jarTest{
- "Check jar.",
- "http://www.host.test",
- []string{},
- "a=1 d=4",
- []query{{"http://www.host.test", "a=1 d=4"}},
- }.run(t, jar)
}
var chromiumDomainTests = [...]jarTest{
@@ -885,7 +890,6 @@ var chromiumDomainTests = [...]jarTest{
}
func TestChromiumDomain(t *testing.T) {
- t.Skip("test is broken on windows/amd64") // issue 4823
jar := newTestJar()
for _, test := range chromiumDomainTests {
test.run(t, jar)
@@ -954,7 +958,6 @@ var chromiumDeletionTests = [...]jarTest{
}
func TestChromiumDeletion(t *testing.T) {
- t.Skip("test is broken on windows/386") // issue 4823
jar := newTestJar()
for _, test := range chromiumDeletionTests {
test.run(t, jar)
コアとなるコードの解説
src/pkg/exp/cookiejar/jar.go
-
Jar
構造体へのnextSeqNum
の追加:type Jar struct { // ... nextSeqNum uint64 }
nextSeqNum
は、Jar
が管理するクッキーに割り当てる次のシーケンス番号を保持します。これにより、新しく追加されるクッキーに一意の識別子を与えることが可能になります。 -
entry
構造体へのseqNum
の追加:type entry struct { // ... seqNum uint64 }
entry
はクッキーの内部表現です。seqNum
フィールドは、同じパス長と作成時間を持つクッキーのソート順序を決定論的にするために使用されます。 -
byPathLength
のLess
メソッドの変更:func (s byPathLength) Less(i, j int) bool { if len(s[i].Path) != len(s[j].Path) { return len(s[i].Path) > len(s[j].Path) } if !s[i].Creation.Equal(s[j].Creation) { return s[i].Creation.Before(s[j].Creation) } return s[i].seqNum < s[j].seqNum }
この変更は、クッキーのソートロジックの核心です。
- まず、パスの長さで比較します(長いパスが優先)。
- パスの長さが同じ場合、作成時間で比較します(古いクッキーが優先)。
- パスの長さも作成時間も同じ場合、新しく導入された
seqNum
で比較します。seqNum
は単調増加するため、これによりソート順序が完全に決定論的になります。
-
Cookies
とSetCookies
のラッパー化とnow
パラメータの導入:func (j *Jar) Cookies(u *url.URL) (cookies []*http.Cookie) { return j.cookies(u, time.Now()) } func (j *Jar) cookies(u *url.URL, now time.Time) (cookies []*http.Cookie) { // ... 実際のロジック ... } func (j *Jar) SetCookies(u *url.URL, cookies []*http.Cookie) { j.setCookies(u, cookies, time.Now()) } func (j *Jar) setCookies(u *url.URL, cookies []*http.Cookie, now time.Time) { // ... 実際のロジック ... }
元の
Cookies
とSetCookies
メソッドは、内部的にtime.Now()
を引数として受け取る新しいプライベートメソッド(cookies
とsetCookies
)を呼び出すようになりました。これにより、テストコードからtime.Now()
の代わりに合成時間を注入できるようになり、テストの制御性と再現性が向上します。 -
setCookies
におけるseqNum
の割り当て:if old, ok := submap[id]; ok { e.Creation = old.Creation e.seqNum = old.seqNum // 既存のクッキーの場合は既存のseqNumを保持 } else { e.Creation = now e.seqNum = j.nextSeqNum // 新しいクッキーの場合は新しいseqNumを割り当て j.nextSeqNum++ } e.LastAccess = now submap[id] = e modified = true // 以前あった now = now.Add(1 * time.Nanosecond) は削除
クッキーが
Jar
に追加される際、既存のクッキーであればそのseqNum
を保持し、新しいクッキーであればj.nextSeqNum
を割り当ててインクリメントします。これにより、クッキーの追加順序がseqNum
に反映され、ソートの決定論性に寄与します。 -
newEntry
における有効期限チェックの修正:if !c.Expires.After(now) { return e, true, nil }
c.Expires.Before(now)
から!c.Expires.After(now)
への変更は、有効期限がnow
と「等しい」場合も期限切れとみなすようにするための修正です。これは、有効期限の境界条件をより正確に扱うためのものです。
src/pkg/exp/cookiejar/jar_test.go
-
tNow
グローバル変数の導入:var tNow = time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC)
テスト全体で使用される合成時間の基準点です。これにより、テストが実際のシステムクロックに依存しなくなります。
-
expiresIn
ヘルパー関数の変更:func expiresIn(delta int) string { t := tNow.Add(time.Duration(delta) * time.Second) return "expires=" + t.Format(time.RFC1123) }
クッキーの
Expires
属性を生成する際に、time.Now()
ではなくtNow
を基準にするようになりました。 -
jarTest.run
メソッドにおける合成時間の使用:func (test jarTest) run(t *testing.T, jar *Jar) { now := tNow // テスト実行時の開始時刻を合成時間で設定 // ... jar.setCookies(mustParseURL(test.fromURL), setCookies, now) // setCookiesに合成時間を渡す now = now.Add(1001 * time.Millisecond) // 時間を進める // ... for i, query := range test.queries { now = now.Add(1001 * time.Millisecond) // 各クエリ実行前に時間を進める for _, c := range jar.cookies(mustParseURL(query.toURL), now) { // cookiesに合成時間を渡す // ... } } }
jarTest.run
内でnow
変数を導入し、これをtNow
から開始して、クッキーの設定や取得の各ステップで明示的に時間を進めるようにしました。これにより、テスト内の時間依存のロジックが完全に制御され、再現性が保証されます。 -
t.Skip
の削除とテストの再有効化:TestUpdateAndDelete
,TestExpiration
,TestChromiumDomain
,TestChromiumDeletion
の各テスト関数から、Windows環境でのスキップを指示していたt.Skip
行が削除されました。これは、上記の変更によりこれらのテストが安定して実行できるようになったためです。特にTestExpiration
は、合成時間を使用するようにテストデータとロジックが大幅に修正されています。
関連リンク
- Go言語の
net/http/cookiejar
パッケージのドキュメント: https://pkg.go.dev/net/http/cookiejar (このコミットはexp/cookiejar
に対するものですが、最終的にはnet/http/cookiejar
に統合されました) - RFC 6265 - HTTP State Management Mechanism: https://datatracker.ietf.org/doc/html/rfc6265
- Public Suffix List: https://publicsuffix.org/
参考にした情報源リンク
- Go言語のコミット履歴 (GitHub): https://github.com/golang/go/commits/master
- Go言語のコードレビューシステム (Gerrit): https://go-review.googlesource.com/ (コミットメッセージに記載されている
https://golang.org/cl/7323063
はこのGerritの変更リストへのリンクです) - Go言語のIssue Tracker: https://github.com/golang/go/issues (コミットメッセージで言及されている
issue 4823
などの詳細を確認できます) - Go言語の
time
パッケージのドキュメント: https://pkg.go.dev/time - Go言語の
sort
パッケージのドキュメント: https://pkg.go.dev/sort - Go言語の
net/url
パッケージのドキュメント: https://pkg.go.dev/net/url