ぼちぼち日記

おそらくプロトコルネタを書いていることが多いんじゃないかと思います。

ネストされたドメインの奇妙な振る舞い

はじめに

ちょっと前の nodejs のメーリングリストで 「Odd behavior of nested Domaons」
https://groups.google.com/d/topic/nodejs/i8NjWjVvk2I/discussion
という質問が投げかけられていました。
私も内容が気になり、動作結果が非常に奇妙に思えたので調べてみました。(ML宛には解答を返事してますのでネタバレ注意です)

問題の奇妙な振る舞い

質問で提示されたコードは下記のものでした。

var domain = require('domain');
var topDomain = domain.create(); //親ドメインの作成
var subDomain = domain.create(); //子ドメインの作成
topDomain.add(subDomain); //親ドメインに子ドメインを追加
topDomain.on("error", function(err) {
  console.log("Top saw an error: " + err.message);
});
subDomain.on("error", function(err) {
  console.log("Sub saw an error: " + err.message); 
  throw new Error("bar"); // 親ドメイン宛に bar 例外スロー
});
topDomain.run( function() {
  subDomain.run(function() {
    throw new Error("foo"); // 子ドメインで foo 例外スロー
  });
});

このコードは、

  • 初めに topDomain と subDomain の2つを作成し、subDomain を topDomain に .add() しておきます。(ドメインの親子関係を明示的にバインドしている)
  • subDomain 内で "foo" エラーのスローを実行すると、subDomain のエラーハンドラで捕獲してコンソールに出力します。
  • ここで "bar" エラーをスローすると親ドメインの topDomain のエラーハンドラで受け、コンソールに出力するだろう。

というのがそもそもの狙いです。
で、これを実際に実行してみると以下の通り出力されます。

> node test10.js
Sub saw an error: foo

/home/ohtsu/tmp/domain_samples/test10.js:10
  throw new Error("bar");
        ^
Error: bar
    at Domain.<anonymous> (/home/ohtsu/tmp/domain_samples/test10.js:10:9)
    at Domain.EventEmitter.emit (events.js:88:17)
    at process.uncaughtHandler (domain.js:61:20)
    at process.EventEmitter.emit (events.js:115:20)

あらっ、 subDomain の方はちゃんとエラーを受けるのに、ドメインの topDomain の方はエラー捕獲しません!
この理由が直感的にはわからず、ソースを調べてみてもしばらく悩みました。

解答編

Node.js では try/catch で捕獲していない例外スローは、 process オブジェクトの uncaughtException イベントで捕獲することができます。Node.js の Domain 実装は、実はこの uncaughtException イベントを使ってドメイン宛の例外スローを捕獲しているんです。ソースでは https://github.com/joyent/node/blob/master/lib/domain.js#L51-68 の部分になります。

process.on('uncaughtException', uncaughtHandler);
function uncaughtHandler(er) {
  // アクティブなドメインが存在して、disposed されていない場合
  if (exports.active && !exports.active._disposed) { 
    util._extend(er, {  // エラーオブジェクトに付加情報をつける
      domain: exports.active,
      domain_thrown: true
    });
    exports.active.emit('error', er); // アクティブなドメインでエラーイベントを発生
    exports.active.exit();
  } else if (process.listeners('uncaughtException').length === 1) {
    throw er;
  }
}

ここでは例外がスローされた時、 uncaughException イベントをひっかけてアクティブドメイン export.active(=process.domain) がどうか判断を行い、ドメインへエラーイベントを emit します。そのため、 subDomain のエラーハンドラ内で再度例外をスローすることは、 uncaughtException で受けたエラーハンドラで再度エラーをスローすることと一緒になるわけです。
例えばこんな感じです。

process.on('uncaughtException', function(e) {
  console.log(e.message);
  throw new Error('bar'); // uncaughtException 内で例外スロー
});
throw new Error('foo'); 

これを実行すると、

> node test08.js
foo

/home/ohtsu/tmp/domain_samples/test08.js:3
  throw new Error('bar');
        ^
Error: bar
    at process.<anonymous> (/home/ohtsu/tmp/domain_samples/test08.js:3:9)
    at process.EventEmitter.emit (events.js:115:20)

先程の出力と全く同じです。
この理由は、 uncaughtException 内で uncaught な例外を再度受けるとループしてしまうことです。だからこれはそのまま終わらせるしかないです*1。ということで納得です。

ドメインで受けたエラーイベントハンドラは uncaughtException のハンドラと同じなので uncaught な例外スローは他のドメインで捕獲することはできない。」

ワークアラウンド

じゃどうしたらいいのかと、もともとの質問のコードに返り回避策を考えたんですが、emit の方は問題なく動作するのでアクティブドメイン(this.domain)に明示的にエラーイベントを発生すればいいんじゃないかと考えました。
こんな感じになります。

var domain = require('domain');
var topDomain = domain.create();
var subDomain = domain.create();
topDomain.add(subDomain);
topDomain.on("error", function(err) {
  console.log("Top saw an error: " + err.message);
});
subDomain.on("error", function(err) {
  console.log("Sub saw an error: " + err.message);
  // バインドされているドメインが存在すれば、エラーイベントを発生
  if (this.domain) this.domain.emit('error',new Error("bar")); 
});
topDomain.run( function() {
  subDomain.run(function() {
    throw new Error("foo");
  });
}); 

出力も見事に期待通りです。

> node test11.js
Sub saw an error: foo
Top saw an error: bar

なんかもやもやしてたのがなくなり、ホントすっきりしました。

*1:ソース的には https://github.com/joyent/node/blob/master/src/node.cc#L1821-1826 です。 event_try_catch を受けたのでソース行付きのエラーレポートを出力してプロセス終了しています