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

[インデックス 13593] ファイルの概要

このコミットは、Go言語の実験的なHTMLパーサー(exp/htmlパッケージ)におけるバグ修正です。具体的には、HTMLドキュメントの<head>要素が閉じられた後に<title>要素が出現した場合の、要素スタックの処理に関する誤りを修正しています。以前は、この状況で<title>要素が誤ってスタックから削除されていましたが、本来削除されるべきは親要素である<head>でした。この修正により、HTMLのパースがより仕様に準拠するようになり、関連する2つのテストがFAILからPASSに変わりました。

コミット

commit 5530a426efed5baa88e47ac73be19d7b7e99d743
Author: Andrew Balholm <andybalholm@gmail.com>
Date:   Tue Aug 7 13:36:08 2012 +1000

    exp/html: correctly handle <title> after </head>
    
    The <title> element was getting removed from the stack of open elements,
    when its parent, the <head> element should have been removed instead.
    
    Pass 2 additional tests.
    
    R=nigeltao
    CC=golang-dev
    https://golang.org/cl/6449101

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/5530a426efed5baa88e47ac73be19d7b7e99d743

元コミット内容

exp/html: </head>の後に来る<title>を正しく処理する。

<title>要素がオープン要素のスタックから削除されていたが、代わりにその親である<head>要素が削除されるべきだった。

2つの追加テストがパスするようになった。

変更の背景

HTMLのパースは複雑なプロセスであり、特にブラウザの挙動を模倣するためには、厳密な仕様(HTML Living Standardなど)に従う必要があります。このコミットが修正している問題は、HTMLパーサーが<head>要素の処理を終えた後(つまり、</head>タグを検出した後)に<title>タグが出現した場合に発生していました。

HTMLの仕様では、<title>要素は通常<head>要素の子として配置されます。しかし、不正なHTMLや、動的に生成されたHTMLなどでは、<head>要素が閉じられた後に<title>要素が出現するケースも考えられます。このような場合、パーサーはHTMLの構造を正しく解釈し、要素スタックを適切に管理する必要があります。

元の実装では、afterHeadIM("after head insertion mode")という状態において、<title>タグが検出された際に、オープン要素のスタックから誤って<title>自身を削除していました。しかし、この状況では、<title>は一時的にスタックに追加され、その後に親である<head>要素がスタックから削除されるべきでした。この誤った処理が、HTMLのツリー構造の不正な構築や、その後のDOM操作における予期せぬ挙動を引き起こす可能性がありました。

このバグは、特定のテストケース(<!doctype html><head></head><title>X</title><!doctype html></head><title>X</title>)で顕在化しており、これらのテストがFAILとなっていたことから、修正の必要性が認識されました。

前提知識の解説

HTMLパーシングと挿入モード

WebブラウザやHTMLパーサーは、HTMLドキュメントを読み込み、それをDOM(Document Object Model)ツリーというメモリ上の構造に変換します。このプロセスは「HTMLパーシング」と呼ばれます。HTMLパーシングは、単にタグを読み込むだけでなく、エラーのあるHTML(ほとんどのWebページは厳密な意味でエラーを含んでいます)を寛容に処理し、ブラウザ間で一貫したDOMツリーを構築するために、非常に複雑なアルゴリズムに従います。

HTML Living Standardには、このパーシングアルゴリズムが詳細に定義されており、その中核となる概念の一つが「挿入モード(Insertion Mode)」です。パーサーは、現在処理しているトークン(タグやテキストなど)と、現在のパーシング状態に基づいて、様々な挿入モード間を遷移します。各挿入モードは、特定のトークンが検出されたときにどのようにDOMツリーを構築し、要素スタックを操作するかを定義しています。

オープン要素のスタック(Stack of Open Elements)

HTMLパーシングにおいて最も重要なデータ構造の一つが「オープン要素のスタック」です。これは、現在開いている(まだ閉じられていない)HTML要素を追跡するために使用されるスタックです。パーサーが開始タグを検出すると、その要素はスタックにプッシュされます。終了タグを検出すると、対応する開始タグがスタックのトップにあることを確認し、その要素をスタックからポップします。

このスタックは、DOMツリーの親子関係を正しく構築するために不可欠です。新しい要素は、通常、スタックのトップにある要素の子として挿入されます。

<head>要素と<title>要素の特殊性

  • <head>要素: HTMLドキュメントのメタデータを含むセクションです。通常、<title>, <meta>, <link>, <style>, <script>などの要素を含みます。HTMLの仕様では、<head>要素は一度しか出現せず、通常は<html>要素の直後に配置されます。
  • <title>要素: ドキュメントのタイトルを定義します。これはブラウザのタブやウィンドウのタイトルバーに表示され、ブックマーク名としても使用されます。<title>要素は、通常<head>要素内に配置されるべきです。

HTMLパーシングアルゴリズムでは、これらの要素、特に<head>要素の処理には特別なルールが適用されます。例えば、<head>要素が明示的に閉じられていない場合でも、特定の要素(例えば<body>や別の<head>)が検出されると、暗黙的に閉じられたと見なされることがあります。

deferキーワード(Go言語)

Go言語のdeferキーワードは、関数がリターンする直前に実行される関数呼び出しをスケジュールするために使用されます。これは、リソースのクリーンアップ(ファイルのクローズ、ロックの解除など)や、今回のケースのように、一時的に変更した状態を元に戻す際に非常に便利です。deferされた関数はLIFO(Last-In, First-Out)順で実行されます。

技術的詳細

このコミットの変更は、Go言語のexp/htmlパッケージ内のparse.goファイル、特にafterHeadIM関数に集中しています。

afterHeadIM関数は、パーサーが「head要素の後にいる挿入モード」(after head insertion mode)にあるときに呼び出されます。このモードは、<head>要素が既に処理され、閉じられた(または暗黙的に閉じられた)後に、パーサーがHTMLストリームを読み進めている状態を指します。

元のコードでは、afterHeadIM関数内で、<base>, <basefont>, <bgsound>, <link>, <meta>, <noframes>, <script>, <style>, <title>といった特定の要素が検出された場合、以下の処理が行われていました。

  1. p.oe = append(p.oe, p.head): オープン要素のスタックp.oeに、パーサーが保持している<head>要素を一時的に追加します。これは、これらの要素が<head>の子であるかのように処理するための一時的な措置です。
  2. defer p.oe.pop(): ここが問題の箇所でした。deferを使って、関数がリターンする際にオープン要素のスタックから一番上の要素をポップ(削除)するようにスケジュールしていました。この場合、スタックのトップには直前に追加された<head>要素ではなく、処理中の<title>要素が一時的に追加され、それがポップされてしまう可能性がありました。
  3. return inHeadIM(p): その後、パーサーは「head要素内挿入モード」(in head insertion mode)に遷移し、検出された要素(例: <title>)を処理します。

問題は、p.oe.pop()がスタックのトップから要素を削除するのに対し、本来削除されるべきは一時的に追加されたp.head要素であったことです。<title>要素が処理され、inHeadIMから戻ってきた後、deferされたp.oe.pop()が実行されると、スタックのトップにある要素(この場合は<title>自身)が削除されてしまい、<head>要素がスタックに残ってしまうか、あるいは<title>が誤ってスタックから消えてしまうという不整合が生じていました。

修正は、defer p.oe.pop()defer p.oe.remove(p.head)に変更することです。

  • p.oe.remove(p.head): このメソッドは、スタックのトップから要素を削除するのではなく、スタック内の特定の要素(この場合はp.head)を検索して削除します。

これにより、<title>要素が処理された後、deferされた呼び出しによって、一時的にスタックに追加された<head>要素が正しく削除されるようになり、オープン要素のスタックの整合性が保たれます。これは、HTMLパーシングアルゴリズムにおける「head要素の後にいる挿入モード」での特定の要素の処理ルールに合致する修正です。

コアとなるコードの変更箇所

diff --git a/src/pkg/exp/html/parse.go b/src/pkg/exp/html/parse.go
index 6b1f40cb8e..0bde2fe0e7 100644
--- a/src/pkg/exp/html/parse.go
+++ b/src/pkg/exp/html/parse.go
@@ -669,7 +669,7 @@ func afterHeadIM(p *parser) bool {
 		\treturn true
 		case a.Base, a.Basefont, a.Bgsound, a.Link, a.Meta, a.Noframes, a.Script, a.Style, a.Title:
 		\tp.oe = append(p.oe, p.head)
-\t\t\tdefer p.oe.pop()\n+\t\t\tdefer p.oe.remove(p.head)\n \t\t\treturn inHeadIM(p)\n \t\tcase a.Head:\
 \t\t\t// Ignore the token.
diff --git a/src/pkg/exp/html/testlogs/tests7.dat.log b/src/pkg/exp/html/testlogs/tests7.dat.log
index 85d6c77088..dfb956b01f 100644
--- a/src/pkg/exp/html/testlogs/tests7.dat.log
+++ b/src/pkg/exp/html/testlogs/tests7.dat.log
@@ -1,7 +1,7 @@
 PASS \"<!doctype html><body><title>X</title>\"\n PASS \"<!doctype html><table><title>X</title></table>\"\n-FAIL \"<!doctype html><head></head><title>X</title>\"\n-FAIL \"<!doctype html></head><title>X</title>\"\n+PASS \"<!doctype html><head></head><title>X</title>\"\n+PASS \"<!doctype html></head><title>X</title>\"\n PASS \"<!doctype html><table><meta></table>\"\n PASS \"<!doctype html><table>X<tr><td><table> <meta></table></table>\"\n PASS \"<!doctype html><html> <head>

コアとなるコードの解説

変更はsrc/pkg/exp/html/parse.goファイルのafterHeadIM関数内の一行です。

// 変更前
defer p.oe.pop()
// 変更後
defer p.oe.remove(p.head)

この変更は、オープン要素のスタックp.oeから要素を削除する方法を変更しています。

  • 変更前 (p.oe.pop()): pop()メソッドは、スタックのLIFO(Last-In, First-Out)原則に従い、常にスタックの一番上にある要素を削除します。このコンテキストでは、p.oe = append(p.oe, p.head)によって一時的にp.headがスタックに追加された後、inHeadIM(p)が呼び出され、その中でさらに要素がスタックに追加される可能性があります。defer p.oe.pop()が実行されるとき、スタックのトップにあるのは、期待されるp.headではなく、inHeadIM内で処理された別の要素(例えば、<title>自身)である可能性がありました。これにより、<title>が誤ってスタックから削除されたり、<head>がスタックに残り続けたりする問題が発生していました。

  • 変更後 (p.oe.remove(p.head)): remove(p.head)メソッドは、スタックから特定の要素(この場合はp.headが指す要素)を検索し、それを削除します。これにより、afterHeadIM関数がリターンする際に、一時的にスタックに追加された<head>要素が確実に、かつ正確に削除されるようになります。スタックの他の要素には影響を与えず、HTMLパーシングの仕様に沿った正しいスタックの状態を維持できるようになります。

この修正により、<!doctype html><head></head><title>X</title><!doctype html></head><title>X</title>のような、<head>要素が閉じられた後に<title>要素が出現するHTMLスニペットが正しくパースされるようになり、関連するテストケースがFAILからPASSに変わりました。これは、HTMLパーサーがより堅牢になり、様々な形式のHTMLドキュメントを正確に処理できるようになるための重要な改善です。

関連リンク

  • Go Gerrit Change-Id: https://golang.org/cl/6449101

参考にした情報源リンク