python / python/cpython

Emscripten: a signal written while the signal buffer is being cleared is lost

オープン
#157,548 コメント 0 件 リアクション 0 件 担当者 0 名 GitHub で見る

まだ誰も着手していません。

interpreter-core OS-emscripten type-bug
主要言語
Python
スター
77.2k
フォーク
35.9k
PR マージ指標
PR 指標を取得中

説明

Bug report

Bug description:

_Py_CheckEmscriptenSignals_Helper in Python/emscripten_signal.c reads the signal buffer and then clears
it as two separate operations:

let result = Module.Py_EmscriptenSignalBuffer[0];
Module.Py_EmscriptenSignalBuffer[0] = 0;
return result;

The buffer is a SharedArrayBuffer written by another thread (see the comment at the top of the file, and
Pyodide's setInterruptBuffer). If that thread stores a signal number after the read and before the clear,
the number is overwritten with 0 and PyErr_SetInterruptEx() is never called for it. Nothing is left
pending, so the signal is not delivered at all.

The helper is called from _PyErr_CheckSignalsTstate() and, every 50th time, from check_periodics() in the
eval loop, so a Python loop calls it constantly. On a busy machine this shows up as a KeyboardInterrupt or
a SIGTERM handler that occasionally never runs.

Atomics.exchange() reads the value and stores 0 in one operation:

return Atomics.exchange(Module.Py_EmscriptenSignalBuffer, 0, 0);

It also accepts a non-shared Uint8Array or Int32Array, so embedders that pass one are unaffected.

The race, reproduced without building CPython (node race.mjs):

import { Worker } from 'node:worker_threads';

const RAISER = `
const { workerData } = require('node:worker_threads');
const cell = new Uint8Array(workerData.cell);
const control = new Int32Array(workerData.control);
let seed = 1;
while (control[0] < workerData.count && control[1] === 0) {
  if (Atomics.load(cell, 0) !== 0) continue;
  seed = (seed * 1103515245 + 12345) & 0x7fffffff;
  for (let spin = seed % 64; spin > 0; spin--);
  if (Atomics.compareExchange(cell, 0, 0, 2) === 0) Atomics.add(control, 0, 1);
}
Atomics.store(control, 2, 1);
`;

const readThenClear = (buf) => { const result = buf[0]; buf[0] = 0; return result; };
const exchange = (buf) => Atomics.exchange(buf, 0, 0);

async function race(name, take, count = 200000) {
  const cell = new Uint8Array(new SharedArrayBuffer(1));
  const control = new Int32Array(new SharedArrayBuffer(12));
  const worker = new Worker(RAISER, { eval: true, workerData: { cell: cell.buffer, control: control.buffer, count } });
  await new Promise((resolve) => worker.once('online', resolve));
  let taken = 0;
  for (const deadline = Date.now() + 10000; taken < count && Date.now() < deadline;) {
    if (take(cell) !== 0) taken++;
  }
  Atomics.store(control, 1, 1);
  while (Atomics.load(control, 2) === 0) Atomics.wait(control, 2, 0, 50);
  await worker.terminate();
  if (take(cell) !== 0) taken++;
  const raised = Atomics.load(control, 0);
  console.log(`${name}: raised ${raised}, taken ${taken}, lost ${raised - taken}`);
}

await race('read, then clear', readThenClear);
await race('Atomics.exchange', exchange);

The raiser only writes when the cell is empty, so every loss is a signal that was never delivered, not two
signals merged into one. Two runs on Node.js 24.18.0:

read, then clear: raised 200000, taken 192995, lost 7005
Atomics.exchange: raised 200000, taken 200000, lost 0
read, then clear: raised 200000, taken 198335, lost 1665
Atomics.exchange: raised 200000, taken 200000, lost 0
The same loss through an interpreter (Pyodide 314.0.5, CPython 3.14.2)

Each SIGUSR1 waits for the handler's acknowledgement before the next is sent, so a missing acknowledgement
is a lost signal. npm install pyodide@314.0.5 && node repro-pyodide.mjs:

import { Worker } from 'node:worker_threads';
import { loadPyodide } from 'pyodide';

const COUNT = 5000;
const py = await loadPyodide();
const cell = new Uint8Array(new SharedArrayBuffer(1));
const control = new Int32Array(new SharedArrayBuffer(20));
py.setInterruptBuffer(cell);

globalThis.acked = () => { Atomics.add(control, 3, 1); Atomics.notify(control, 3); };
py.runPython(`
import signal
from js import acked
got = 0
def handler(signum, frame):
    global got
    got += 1
    acked()
signal.signal(signal.SIGUSR1, handler)
`);

const worker = new Worker(`
const { workerData } = require('node:worker_threads');
const cell = new Uint8Array(workerData.cell);
const control = new Int32Array(workerData.control);
let seed = 1;
while (control[0] < workerData.count) {
  if (Atomics.load(cell, 0) !== 0) continue;
  seed = (seed * 1103515245 + 12345) & 0x7fffffff;
  for (let spin = seed % 32768; spin > 0; spin--);
  if (Atomics.compareExchange(cell, 0, 0, 10) !== 0) continue;
  const raised = Atomics.add(control, 0, 1) + 1;
  const until = Date.now() + 3000;
  while (Atomics.load(control, 3) < raised && Date.now() < until) Atomics.wait(control, 3, Atomics.load(control, 3), 50);
  if (Atomics.load(control, 3) < raised) { Atomics.store(control, 4, raised); break; }
}
`, { eval: true, workerData: { cell: cell.buffer, control: control.buffer, count: COUNT } });
await new Promise((resolve) => worker.once('online', resolve));

const got = py.runPython(`
import time
end = time.monotonic() + 15
while got < ${COUNT} and time.monotonic() < end:
    pass
got`);
await worker.terminate();
const lost = Atomics.load(control, 4);
console.log(lost ? `signal ${lost} was raised and never handled (handler ran ${got} times)` : `all ${got} signals handled`);
process.exit(0);

Three runs:

signal 1569 was raised and never handled (handler ran 1568 times)
signal 287 was raised and never handled (handler ran 286 times)
signal 125 was raised and never handled (handler ran 124 times)

The helper dates from bpo-47176. It is the same on main, 3.15, 3.14 and 3.13.

CPython versions tested on:

3.14

Operating systems tested on:

Other

Linked PRs
  • gh-157553

コントリビューションガイド

コントリビューションガイドを開く

はじめの一歩

  1. issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
  2. 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
  3. リポジトリをフォークし、ブランチを切って変更します。
  4. issue 番号を参照したプルリクエストを送ります。

調査の方向性

Python/emscripten_signal.c の _Py_CheckEmscriptenSignals_Helper から始め、次に _PyErr_CheckSignalsTstate() と check_periodics() にあるその呼び出し元を追跡します。報告されている read-and-clear race を Atomics.exchange の動作と比較し、提供されている Node.js の race 再現を実行します。完了の条件は、同時発生するシグナルが失われないことです。リンクされた PR gh-157553 は、すでに作業が進行中であることを示しています。

索引モデルが issue の本文から書いたものです。

評価

技術スタック
c, javascript, python
領域
operating-systems
issue の種類
バグ
難易度
2/5
見積もり時間
1〜3時間
活発さ
停滞
明瞭さ
明確に書かれている
初心者へのやさしさ
35/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。