Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 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上でTestUpdateAndDeleteTestExpirationTestChromiumDomainTestChromiumDeletionを再有効化する。
  • 同じパス長と作成時間を持つクッキーのソートは、追加のseqNumフィールドによって行われる。これにより、システムクロックが操作されたり、低解像度クロックを持つシステムでも、Cookiesメソッドで返されるクッキーの順序が決定論的になる。
  • テストは合成時間を使用するようになった。これにより、不正なシステムクロックの場合でもクッキーのテストが信頼できるようになり、有効期限テストが高速化される。

変更の背景

このコミットが行われた背景には、主に以下の2つの問題がありました。

  1. クッキーソートの非決定性: exp/cookiejarパッケージは、HTTPクッキーを管理するための機能を提供します。RFC 6265などの標準に従ってクッキーを処理する際、複数のクッキーが同じドメイン、パス、作成時間を持つ場合、それらのクッキーが返される順序がシステム環境(特にシステムクロックの解像度)によって非決定論的になる可能性がありました。これは、テストの再現性を損ない、デバッグを困難にする原因となります。特に、Windows環境ではこの問題が顕著であったようです(コミットメッセージのissue 4823への言及から推測されます)。
  2. テストの不安定性: クッキーの有効期限や更新、削除に関するテストは、時間の経過に依存します。実際のシステムクロックを使用すると、テスト実行時の環境(特にクロックの精度や操作)によって結果が変動し、テストが不安定になることがありました。また、有効期限が切れるのを待つために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.ukeTLD+1となります。クッキーのドメイン属性の検証に用いられます。

技術的詳細

このコミットの技術的な変更点は大きく分けて2つあります。

  1. クッキーソートの決定論化:

    • 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の導入により不要になったためです。
  2. テストにおける合成時間の導入:

    • 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を基にした合成時間がsetCookiescookiesメソッドに渡されるようになりました。これにより、テストの実行速度が向上し、システムクロックの変動に影響されなくなりました。
    • Windows環境でスキップされていたTestUpdateAndDelete, TestExpiration, TestChromiumDomain, TestChromiumDeletiont.Skip呼び出しが削除され、これらのテストが再有効化されました。これは、ソートの決定論化と合成時間の導入により、これらのテストが安定して実行できるようになったためです。
    • newEntry関数におけるクッキーの有効期限チェックロジックがif c.Expires.Before(now)からif !c.Expires.After(now)に変更されました。これは、Expiresnowと等しい場合(つまり、ちょうど有効期限が切れた瞬間)も期限切れとみなすようにするための、より厳密なチェックです。

これらの変更により、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

  1. Jar構造体へのnextSeqNumの追加:

    type Jar struct {
        // ...
        nextSeqNum uint64
    }
    

    nextSeqNumは、Jarが管理するクッキーに割り当てる次のシーケンス番号を保持します。これにより、新しく追加されるクッキーに一意の識別子を与えることが可能になります。

  2. entry構造体へのseqNumの追加:

    type entry struct {
        // ...
        seqNum uint64
    }
    

    entryはクッキーの内部表現です。seqNumフィールドは、同じパス長と作成時間を持つクッキーのソート順序を決定論的にするために使用されます。

  3. byPathLengthLessメソッドの変更:

    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は単調増加するため、これによりソート順序が完全に決定論的になります。
  4. CookiesSetCookiesのラッパー化と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) {
        // ... 実際のロジック ...
    }
    

    元のCookiesSetCookiesメソッドは、内部的にtime.Now()を引数として受け取る新しいプライベートメソッド(cookiessetCookies)を呼び出すようになりました。これにより、テストコードからtime.Now()の代わりに合成時間を注入できるようになり、テストの制御性と再現性が向上します。

  5. 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に反映され、ソートの決定論性に寄与します。

  6. 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

  1. tNowグローバル変数の導入:

    var tNow = time.Date(2013, 1, 1, 12, 0, 0, 0, time.UTC)
    

    テスト全体で使用される合成時間の基準点です。これにより、テストが実際のシステムクロックに依存しなくなります。

  2. expiresInヘルパー関数の変更:

    func expiresIn(delta int) string {
        t := tNow.Add(time.Duration(delta) * time.Second)
        return "expires=" + t.Format(time.RFC1123)
    }
    

    クッキーのExpires属性を生成する際に、time.Now()ではなくtNowを基準にするようになりました。

  3. 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から開始して、クッキーの設定や取得の各ステップで明示的に時間を進めるようにしました。これにより、テスト内の時間依存のロジックが完全に制御され、再現性が保証されます。

  4. t.Skipの削除とテストの再有効化: TestUpdateAndDelete, TestExpiration, TestChromiumDomain, TestChromiumDeletionの各テスト関数から、Windows環境でのスキップを指示していたt.Skip行が削除されました。これは、上記の変更によりこれらのテストが安定して実行できるようになったためです。特にTestExpirationは、合成時間を使用するようにテストデータとロジックが大幅に修正されています。

関連リンク

参考にした情報源リンク