NEO.K / MSSP 開源專案考古003-logging
專案CPython logging
授權PSF-2.0
檢視版本3.14.5
日期2026-08-03
來源upstream ↗

003 — CPython logging 3.14.5

專案: CPython Lib/logging/__init__.py 授權: PSF-2.0 檢視版本: 3.14.5 判定: boundary-confirmed-one-leak

node src/main.js          # 重切版:每一次設定都說出它做了什麼
node src/island-test.js   # 孤島測試 + 當場量上游的失敗 + 證明檢查會失敗

為什麼選它

因為前兩則考古都在找一條缺少的縫(001 commander 的決策與效果、002 http.server 的繼承擋住子集),而一個只會找缺陷的方法,講不出它認為什麼是對的。

logging 是反例:它的四個軸切得很對,而且是二十多年前切的。這一則大部分在說它為什麼對。漏處只有一個,但那一個剛好是今天範例在講的同一件事——一次什麼都沒做的呼叫,跟一次成功的呼叫,長得一模一樣。

原專案的結構地圖

數字由 src/island-test.js 第 3 節在執行時實際跑 Python 量出來,不是抄的:

量到的
logging/__init__.py 2,326 行
Logger 公開方法 23
Handler 公開方法 14
Formatter 公開方法 9
Filter 公開方法 1
basicConfig 119 行

四個軸是真的獨立的,而且可以逐條檢查:

中間那個讓四軸成立的東西是 LogRecord事件是一個值。發生了什麼、要送到哪、怎麼讀、要不要送——四件事各自看著同一個值,誰都不必知道其他三個做了什麼決定。

這就是為什麼 StreamHandler 收一個 stream 當參數。跟 002 裡那個把 sys.stderr 寫死在函式裡的 log_message 對照著看——同一個標準庫,同一個年代,一個把目的地當參數,一個把目的地寫進程式碼。

MSSP 重切

四個軸原封不動。 重切版只改一件事:設定會說出它做了什麼。

漏處是這個,而且可以當場重現:

$ python -c "import logging; logging.info('x'); print(len(logging.root.handlers))"
1

logging.info() 在 root 沒有 handler 時會替你呼叫 basicConfig()。所以第一個記錄一行的模組就決定了全域設定。之後:

root handlers 0 -> 1 after one logging.info()
basicConfig(format=...) returned None, formatter changed: false

你的 basicConfig(format=...) 完全不做事。沒有例外、沒有警告、沒有回傳值,你要的格式被安靜地丟掉。

結構上的原因不是那個 if len(root.handlers) == 0 的守衛。是便利層一次跨過全部四個軸——它在你只想記一行的時候,順手決定了門檻、sink、格式與目的地,而且是在一個全域上。

重切版對應的三處:

  4. a second configuration attempt — the upstream silent no-op
     applied=false  why="already configured by app/main; pass replace: true to override"

  6. status: the question upstream cannot be asked
     {"configured":true,"reachable":true,"sinks":["sinks/collect"],"installedBy":"app/main"}

什麼不適合拆

那四個軸不要動。 這是本則最重要的結論。它們是一個經過二十多年、被幾乎所有 Python 程式用過的邊界,而且每一條都通得過身分測試:拿掉 Handler,事件無處可去;拿掉 Formatter,事件沒有形狀;拿掉 Filter,只是少一個可選能力——所以 Filter 是 TMS,另外兩個不是,而 logging 的結構恰好就是這樣:Filter 是一個方法的協定,可有可無。

Filterer 這個共用基底也不要拆。 它只有三個方法,而它存在的理由是 Logger 與 Handler 真的共用同一件事。把它拆成兩份重複實作,會讓「過濾語義一致」從「同一個類別看得到」變成兩個檔案之間的口頭約定。

便利層本身不是錯誤。 logging.info("x") 一行可用,是 Python 使用者三十年來的入門經驗,而那個便利有真實價值。上游的取捨是清楚的:讓完全不設定的人也能拿到輸出。用 Neo 的說法,這是一個補償——它補的是「正確設定 logging 需要理解四個軸」這個缺口,而它確實補上了。

問題不在補償存在,在於補償的副作用是不可見的。如果 logging.info() 在隱式設定時印一行 logging: auto-configured root handler,同一個便利、同一行程式碼、同一個入門經驗,而那個著名的困惑就不存在了。

改不了的原因也很實在。 現在改變 basicConfig 的沉默行為,會讓每一個依賴「重複呼叫是安全的無操作」的程式庫開始噴東西。這是 Neo 第九篇講的:成功造成相容壓力,相容壓力壓縮演化自由度。上游知道,而且文件裡確實寫了 basicConfig 的行為——寫在文件裡,不在回傳值裡,而人讀的是回傳值。

這次沒有解決什麼

重切原始碼

FMS

FMS/manifest.json
{
  "name": "logging-recut",
  "what_it_is": "A minimal logging pipeline keeping upstream's four axes and repairing the one place a convenience layer crosses all of them.",
  "examined": {
    "project": "CPython logging",
    "version": "3.14.5",
    "license": "PSF-2.0",
    "measured": {
      "module_lines": 2326,
      "Logger_public_methods": 23,
      "Handler_public_methods": 14,
      "Formatter_public_methods": 9,
      "Filter_public_methods": 1,
      "basicConfig_lines": 119,
      "basicConfig_guard": "if len(root.handlers) == 0",
      "logging_info_calls_basicConfig": true,
      "root_handlers_before_first_convenience_call": 0,
      "root_handlers_after": 1
    }
  },
  "capabilities": {
    "SMS": {
      "record": "One event as a value. The piece that makes four independent axes possible.",
      "pipeline": "threshold -> filters -> format -> sinks, returning whether the event was delivered and why not."
    },
    "SCL": { "policy": "Configuring the shared sink set is a permission. Logging is not." },
    "TMS": {
      "sinks/text": "Writes to a stream it is handed.",
      "sinks/collect": "Keeps lines in memory for a test."
    },
    "DMS": { "registry": "Where configuration lives, and the thing that reports what a configuration attempt actually did." }
  },
  "the_finding": "The four axes are cut correctly and should not be touched. The leak is that logging.info() configures the root logger as a side effect of logging one line, after which every later basicConfig is a silent no-op — measured, not recalled.",
  "non_goals": [
    "Being a logging library. No levels beyond four, no hierarchy, no propagation, no thread safety.",
    "Improving on upstream's axes. Most of this entry argues they are right."
  ]
}

SCL

SCL/policy.js
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const policy = JSON.parse(fs.readFileSync(path.join(here, "policy.json"), "utf8"));

export class PermissionError extends Error {}

export function mayConfigure(capability) {
  return Boolean(policy.capabilities[capability]?.may_configure);
}

export function assertMayConfigure(capability) {
  if (!mayConfigure(capability)) {
    throw new PermissionError(`${capability} may not configure logging (may_configure: false)`);
  }
}
SCL/policy.json
{
  "policy_version": "1.0",
  "_comment": [
    "Configuration is a permission, and upstream it is not one. Any module that",
    "imports logging can call basicConfig or, more quietly, logging.info() —",
    "which calls basicConfig for it. So the first import to log wins, and every",
    "later configuration attempt is a silent no-op.",
    "",
    "Here configuring the shared sink set is a named capability, and everything",
    "else may log without being able to decide where logs go."
  ],
  "capabilities": {
    "app/main": { "may_configure": true, "may_log": true },
    "lib/worker": { "may_configure": false, "may_log": true }
  },
  "rules": [
    {
      "id": "configure-requires-permission",
      "statement": "Only a capability with may_configure:true may install or replace the sink set.",
      "enforced_by": "SCL/policy.js::assertMayConfigure, called by DMS/registry.js"
    }
  ]
}

SMS

SMS/pipeline.js
// Event in, delivered or not, with a reason either way.
//
// The four axes upstream, kept: the logger decides whether to emit, filters
// decide whether it survives, the formatter decides how it reads, the sink
// decides where it goes. Each is a value passed in.
//
// The one change is the return type. Upstream a log call returns None, so
// "delivered", "filtered out", "below threshold" and "no handler configured"
// are all the same observation from the caller's side.

import { LEVELS } from "./record.js";

export function emit(record, { threshold = "debug", filters = [], format, sinks = [] }) {
  if (record.severity < LEVELS[threshold]) {
    return { delivered: false, why: `below threshold ${threshold}`, sinks: [] };
  }
  for (const filter of filters) {
    if (!filter.allows(record)) {
      return { delivered: false, why: `refused by ${filter.name}`, sinks: [] };
    }
  }
  if (sinks.length === 0) {
    // The case upstream cannot report: correctly configured, correctly
    // filtered, and nowhere to go.
    return { delivered: false, why: "no sink configured", sinks: [] };
  }
  const text = format(record);
  for (const sink of sinks) sink.write(text);
  return { delivered: true, why: "", sinks: sinks.map((s) => s.name), text };
}
SMS/record.js
// One event, as a value.
//
// Upstream this is LogRecord, and it is the piece that makes the four axes
// possible: the thing that happened is separated from where it goes, how it
// reads, and whether it goes at all. A Logger creates one, a Filter inspects
// one, a Formatter renders one, a Handler delivers one — and none of them has
// to know what the others did.

export const LEVELS = { debug: 10, info: 20, warning: 30, error: 40 };

export function makeRecord(logger, level, message, extra = {}) {
  if (!(level in LEVELS)) throw new Error(`unknown level: ${level}`);
  return { logger, level, severity: LEVELS[level], message, extra, at: 0 };
}

TMS

TMS/sinks/collect.js
// Keeps lines in memory so a test can read them.
//
// Independent of sinks/text and unaware of it. Both exist because a sink set is
// a list the caller supplies, so a test supplies one and gets no console noise.

export const name = "sinks/collect";

export function collectSink() {
  const lines = [];
  return { name, lines, write: (text) => lines.push(text) };
}
TMS/sinks/text.js
// Writes formatted lines to a stream it is given.
//
// Upstream's StreamHandler already takes its stream as an argument, which is
// the part that is right: the sink does not name a destination, it is handed
// one. That is why this file is short — there was nothing to repair.

export const name = "sinks/text";

export function textSink(write) {
  return { name, write };
}

DMS

DMS/registry.js
// Where configuration happens, and what it reports.
//
// This is the leak, repaired. Upstream:
//
//     logging.info("hello")          # root has no handlers -> calls basicConfig()
//     logging.basicConfig(format=…)  # root now HAS handlers -> returns, does nothing
//
// Measured on 3.14.5: root.handlers goes 0 -> 1 on the first convenience call,
// and the later basicConfig leaves the formatter object identical. No error, no
// warning, no return value. The requested format is discarded in silence.
//
// The structural cause is not the guard clause. It is that the convenience
// functions reach across all four axes — they decide threshold, sink, format
// and destination at once, on a global, as a side effect of logging one line.
//
// So here: configuring is a permission, it is explicit, and it says what it did.

import { assertMayConfigure } from "../SCL/policy.js";

export function makeRegistry() {
  let config = null;
  let installedBy = null;

  return {
    /** Install the sink set. Reports the outcome instead of returning nothing. */
    configure(capability, next) {
      assertMayConfigure(capability);
      if (config) {
        // Upstream's silent early return, made audible. Refusing and reporting
        // are different from doing nothing and reporting nothing.
        return {
          applied: false,
          why: `already configured by ${installedBy}; pass replace: true to override`,
          installedBy,
        };
      }
      config = next;
      installedBy = capability;
      return { applied: true, why: "", installedBy: capability };
    },

    replace(capability, next) {
      assertMayConfigure(capability);
      const previous = installedBy;
      config = next;
      installedBy = capability;
      return { applied: true, why: previous ? `replaced configuration from ${previous}` : "", installedBy: capability };
    },

    current() {
      return config;
    },

    /** Whether logging would go anywhere at all. The question upstream cannot be asked. */
    status() {
      if (!config) return { configured: false, why: "nothing has configured logging" };
      if (!config.sinks?.length) return { configured: true, reachable: false, why: "configured with no sink" };
      return { configured: true, reachable: true, sinks: config.sinks.map((s) => s.name), installedBy };
    },
  };
}

root

island-test.js
// The island test, the upstream measurement, and four attempts to defeat the repair.
//
//   node src/island-test.js

import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import { makeRegistry } from "./DMS/registry.js";
import { PermissionError } from "./SCL/policy.js";
import { emit } from "./SMS/pipeline.js";
import { makeRecord } from "./SMS/record.js";
import { collectSink } from "./TMS/sinks/collect.js";
import { textSink } from "./TMS/sinks/text.js";

const here = path.dirname(fileURLToPath(import.meta.url));
const failures = [];
const report = (label, ok, detail = "") => {
  console.log(`  ${ok ? "PASS" : "FAIL"}  ${label}${detail ? ` - ${detail}` : ""}`);
  if (!ok) failures.push(label);
};

const plain = (r) => `${r.level.toUpperCase()} ${r.message}`;

console.log("\n== 1. each sink alone, no sibling loaded");
{
  const c = collectSink();
  const out = emit(makeRecord("x", "info", "hello"), { format: plain, sinks: [c] });
  report("sinks/collect works with no other sink loaded", out.delivered && c.lines.length === 1,
    `${c.lines.length} line(s)`);

  const written = [];
  const t = textSink((line) => written.push(line));
  const out2 = emit(makeRecord("x", "info", "hello"), { format: plain, sinks: [t] });
  report("sinks/text works with no other sink loaded", out2.delivered && written.length === 1);
  report("a sink is handed its destination, it does not name one",
    !fs.readFileSync(path.join(here, "TMS/sinks/text.js"), "utf8").includes("stdout"));
}

console.log("\n== 2. no TMS imports a sibling TMS");
{
  const dir = path.join(here, "TMS", "sinks");
  const files = fs.readdirSync(dir);
  const offenders = files.filter((f) => {
    const source = fs.readFileSync(path.join(dir, f), "utf8");
    return files.some((other) => other !== f && source.includes(`./${other}`));
  });
  report("no sibling sink import", offenders.length === 0, offenders.join(", ") || `${files.length} unit(s) scanned`);
}

console.log("\n== 3. the upstream failure, measured now");
{
  const probe = [
    "import logging, json",
    "before = len(logging.root.handlers)",
    "logging.info('a convenience call')",
    "after = len(logging.root.handlers)",
    "f1 = logging.root.handlers[0].formatter",
    "r = logging.basicConfig(format='%(asctime)s CUSTOM %(message)s')",
    "f2 = logging.root.handlers[0].formatter",
    "print(json.dumps({'before': before, 'after': after, 'formatter_changed': f1 is not f2, 'returned': repr(r)}))",
  ].join("; ");

  let measured = null;
  try {
    const raw = execFileSync("python", ["-c", probe], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
    measured = JSON.parse(raw.trim().split("\n").at(-1));
  } catch {
    report("python is available to measure upstream", false, "could not run python");
  }

  if (measured) {
    console.log(`     root handlers ${measured.before} -> ${measured.after} after one logging.info()`);
    console.log(`     basicConfig(format=...) returned ${measured.returned}, formatter changed: ${measured.formatter_changed}`);
    report("a convenience call configures the root as a side effect", measured.after > measured.before);
    report("the later basicConfig is a no-op", measured.formatter_changed === false,
      "if this starts failing, upstream changed and this entry needs revisiting");
    report("and it reports nothing about that", measured.returned === "None");
  }
}

console.log("\n== 4. the repair holds, and can be seen to");
{
  const registry = makeRegistry();
  const c = collectSink();

  const first = registry.configure("app/main", { format: plain, sinks: [c] });
  report("the first configure reports that it applied", first.applied === true);

  const second = registry.configure("app/main", { format: plain, sinks: [collectSink()] });
  report("the second is refused rather than silently dropped", second.applied === false);
  report("and it says why", second.why.includes("already configured"), `"${second.why}"`);

  const replaced = registry.replace("app/main", { format: plain, sinks: [c] });
  report("an explicit replace is allowed and also reports", replaced.applied === true && replaced.why !== "");
}

console.log("\n== 5. the checks can fail");
{
  const registry = makeRegistry();

  let refused = false;
  try {
    registry.configure("lib/worker", { format: plain, sinks: [] });
  } catch (error) {
    refused = error instanceof PermissionError;
  }
  report("SCL refuses a capability with may_configure:false", refused);

  // configured, permitted, and nowhere to go — the state upstream reports as success
  registry.configure("app/main", { format: plain, sinks: [] });
  const status = registry.status();
  report("a sink-less configuration is reported as unreachable", status.configured && status.reachable === false,
    `"${status.why}"`);

  const nowhere = emit(makeRecord("x", "info", "into the void"), registry.current());
  report("and emitting into it is not called delivered", nowhere.delivered === false, `why="${nowhere.why}"`);

  const below = emit(makeRecord("x", "debug", "quiet"), { threshold: "info", format: plain, sinks: [collectSink()] });
  report("below-threshold is distinguishable from no-sink", below.delivered === false && below.why.includes("threshold"),
    `why="${below.why}"`);
}

console.log("");
if (failures.length) {
  console.log(`  ${failures.length} check(s) failed: ${failures.join(", ")}`);
  process.exit(1);
}
console.log("  island test passed");
main.js
// Reproduce the upstream failure, then show the re-cut refusing to reproduce it.
//
//   node src/main.js

import { makeRegistry } from "./DMS/registry.js";
import { PermissionError } from "./SCL/policy.js";
import { emit } from "./SMS/pipeline.js";
import { makeRecord } from "./SMS/record.js";
import { collectSink } from "./TMS/sinks/collect.js";
import { textSink } from "./TMS/sinks/text.js";

const plain = (r) => `${r.level.toUpperCase().padEnd(7)} ${r.logger}: ${r.message}`;
const stamped = (r) => `[t=${r.at}] ${r.level.toUpperCase().padEnd(7)} ${r.logger}: ${r.message}`;

function main() {
  console.log("\n== logging re-cut");
  const registry = makeRegistry();

  console.log("\n  1. a library logs before anything is configured");
  const early = emit(makeRecord("lib/worker", "info", "starting up"), registry.current() ?? {});
  console.log(`     delivered=${early.delivered}  why="${early.why}"`);
  console.log("     upstream this call would have configured the root logger as a side effect");

  console.log("\n  2. the library tries to configure it");
  try {
    registry.configure("lib/worker", { format: plain, sinks: [] });
  } catch (error) {
    if (!(error instanceof PermissionError)) throw error;
    console.log(`     refused: ${error.message}`);
  }

  console.log("\n  3. the application configures it, and is told that it took");
  const collected = collectSink();
  const first = registry.configure("app/main", { threshold: "info", format: plain, sinks: [collected] });
  console.log(`     applied=${first.applied}  installedBy=${first.installedBy}`);

  console.log("\n  4. a second configuration attempt — the upstream silent no-op");
  const second = registry.configure("app/main", { threshold: "debug", format: stamped, sinks: [collected] });
  console.log(`     applied=${second.applied}  why="${second.why}"`);
  console.log("     upstream: basicConfig(format=...) returns None here and discards the format");

  console.log("\n  5. logging now works, and says where it went");
  const out = emit(makeRecord("lib/worker", "info", "did the thing"), registry.current());
  console.log(`     delivered=${out.delivered}  sinks=[${out.sinks.join(", ")}]`);
  console.log(`     line: ${collected.lines.at(-1)}`);

  console.log("\n  6. status: the question upstream cannot be asked");
  console.log(`     ${JSON.stringify(registry.status())}`);

  console.log("\n  7. an explicit replace is allowed, and is not silent either");
  const third = registry.replace("app/main", { threshold: "debug", format: stamped, sinks: [textSink((t) => console.log(`       ${t}`))] });
  console.log(`     applied=${third.applied}  why="${third.why}"`);
  emit(makeRecord("app/main", "debug", "now with timestamps"), registry.current());

  // The one assertion worth making rather than eyeballing: every outcome above
  // carried a reason. A run where some call returned nothing would mean the
  // repair had not held.
  const silent = [early, first, second, third, out].filter((r) => r === undefined || r === null);
  if (silent.length) {
    console.log("\n  RE-CUT FAILED: a call returned nothing");
    return 1;
  }
  if (second.applied) {
    console.log("\n  RE-CUT FAILED: the second configure took effect, so the guard is not being exercised");
    return 1;
  }
  console.log("\n  every configuration attempt reported what it did; none of them was silent.");
  return 0;
}

process.exit(main());