From be911e08cc43a469861920f9dddc5f08cc900510 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 23:46:12 +0000 Subject: [PATCH] =?UTF-8?q?chore(mcp):=20=D7=90=D7=A8=D7=91=D7=A2=D7=94=20?= =?UTF-8?q?=D7=A4=D7=A8=D7=99=D7=98=D7=99=D7=9D=20=D7=A7=D7=98=D7=A0=D7=99?= =?UTF-8?q?=D7=9D=20=D7=9E=D7=A1=D7=A7=D7=99=D7=A8=D7=AA=20#3429=20?= =?UTF-8?q?=E2=80=94=20SUGG-014,=20SUGG-001,=20SUGG-004,=20SUGG-010=20(#34?= =?UTF-8?q?33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SUGG-004: שורת הקיבולת קוראת את ThreadPoolExecutor._max_workers הפרטי דרך _installed_width, וכשהמאפיין ייעלם היא מדפיסה את הרוחב שהתבקש עם "(requested; installed width unreadable)" ו-WARNING שאומר למה — במקום שהשירות לא יעלה בגלל שורת לוג, ובמקום להדהד את הבקשה כאילו היא המצב (state-record-without-state-change). אותו כלל גם ב-WARNING של ה-fallback. - SUGG-010: md_parser.token_count — פונקציה ציבורית ומתועדת שסופרת טוקנים על _MD, המופע שהכלי מריץ — במקום שהסקריפט יקרא ל-_build_parser הפרטית ויבנה פרסר חדש לכל קובץ. מחוץ ל-__all__ בכוונה: הרשימה שם היא החוזה של "שני פארסרים בני-החלפה", וטסט מקבע את ההפרש בינה לבין זו של rst_parser. - SUGG-014: שני טסטים לקובץ memory.max שקיים אבל ריק (IndexError) או לא-מספרי (ValueError) — שניהם נופלים ל-cgroup v1 ומחזירים את הערך שלו. כיסוי השורות היה מלא; התרחיש חסר. מוטציה שמסירה כל אחת מהחריגות מה-except מפילה את הטסט שלה. - SUGG-001: האסרשן שלא יכול היה ליפול הוחלף: os.cpu_count מוצמד ל-16 כדי ש"cpu_count + 4" יהיה 20, מספר שהרצפה לעולם אינה — ומוטציה שמחזירה את ה-fallback ל-min(32, cpu_count + 4) מפילה אותו. על origin/main: שלושה טסטים נופלים (הרוחב הלא-קריא, token_count, הסקריפט דרך הפונקציה הציבורית); שלושת האחרים עוברים שם ומוכחים במוטציות. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01SfJTSpDAhDr2yhtmpFkwTx --- docs/whats-new.rst | 1 + mcp_server/server.py | 42 +++++++++++-- scripts/measure_md_parse_cost.py | 2 +- services/md_parser.py | 19 ++++++ tests/test_mcp_to_thread.py | 72 ++++++++++++++++++++-- tests/test_md_parser.py | 13 ++++ tests/test_measure_md_parse_cost_script.py | 13 ++++ 7 files changed, 152 insertions(+), 10 deletions(-) diff --git a/docs/whats-new.rst b/docs/whats-new.rst index ef65c8cd0..1670fbad4 100644 --- a/docs/whats-new.rst +++ b/docs/whats-new.rst @@ -4,6 +4,7 @@ What's New 2026-09-20 ---------- +- **chore (MCP): ארבעה פריטים קטנים מסקירת #3429 (#3433).** שורת הקיבולת של מאגר הקריאות קוראת את ``ThreadPoolExecutor._max_workers`` הפרטי דרך ``_installed_width``, וכשהמאפיין ייעלם היא אומרת "unreadable" לצד הרוחב שהתבקש עם WARNING, במקום שהשירות לא יעלה בגלל שורת לוג (SUGG-004); ‏``scripts/measure_md_parse_cost.py`` מודד צפיפות דרך ``md_parser.token_count`` הציבורית — על המופע שהכלי מריץ — במקום לקרוא ל-``_build_parser`` הפרטית ולבנות פרסר לכל קובץ (SUGG-010); שני טסטים חדשים מקבעים שקובץ ``memory.max`` שקיים אבל ריק או לא-מספרי נופל ל-cgroup v1 (SUGG-014); ואסרשן שלא יכול היה ליפול הוחלף בטסט שמצמיד ``os.cpu_count`` ל-16 כדי שה-fallback ייבדק מול 20 ולא מול עצמו (SUGG-001). - **feat: ``codekeeper_docs_get_section`` קורא גם קובצי Markdown, ולכל ריפו מדיניות נתיבים משלו.** עד היום הכלי ידע לקרוא ``.rst`` בלבד, ורק מתחת ל-``docs/``. הפארסר של Markdown נכתב ונמדד כבר קודם — ואף אחד לא קרא לו; זה השינוי שמחווט אותו. מעכשיו הסיומת בוחרת את הפארסר, ולכל ריפו מוצהר **איפה התיעוד שלו יושב ובאיזה פורמט**: ``CodeBot`` נשאר ``docs/`` עם ``.rst`` ללא שינוי, ו-``amir-bug-patterns`` מגיש ``.md`` משורש הריפו. **וזו הרחבה מכוונת של גבול הקריאה** — הכלי ציבורי, כלומר כל משתמש מאומת מפעיל אותו בלי אדמין, והריפו שנוסף ציבורי ב-GitHub. מה ש**לא** השתנה: מדיניות סינון הסודות חלה על המסלול הזה במלואה, ולכן ``path="secrets"`` מחזיר ``path_denied`` ולא ``not_found``, וגם ``secrets.md`` ו-``credentials.md`` חסומים; ותקרת 500KB של שירות המראה נשארת ההגנה על הפרסור, כי הכלי קורא את הקובץ כולו בלי ``lines``. **שני שערים ולא אחד:** ``MCP_DOCS_REPO`` מתיר בזמן ריצה, טבלת המדיניות בקוד יודעת לקרוא, וריפו שנמצא במשתנה הסביבה ואין לו מדיניות נדחה ב-``repo_not_configured`` במקום ליפול לברירת מחדל מתירנית. **ובקשה לפורמט שהריפו אינו מגיש נדחית בשמה** — ``suffix_not_allowed`` עם רשימת הסיומות שכן מוגשות, במקום להשלים סיומת בשקט מעל הקיימת ולחזור כ"לא נמצא" על קובץ שקיים. שני סירובים חדשים מגיעים מהפרסור עצמו: ``inconsistent_line_endings`` על ``\r`` בודד (CRLF עובד במלואו), ו-``too_many_sections`` על קובץ שחוצה את תקרת 50,000 הכותרות — Markdown דרך ברירת המחדל של הפרסר, ו-RST דרך התקרה שהכלי מעביר מאז #3429; סירוב שהפארסר מספק לו מספר שורה נושא גם ``line`` — תמיד ב-Markdown, ולא ב-``too_many_sections`` של RST, כי ``rst_parser`` מרים אותה בלי מספר. ``includes`` הוא שדה של RST ולכן בעמוד ``.md`` הוא תמיד ריק. **ומסלול ה-RST לא זז**: אפס דיף מדוד על 5,920 רשומות מ-208 קובצי RST, לפני ואחרי. ראו :ref:`mcp-docs-path-policy`. - **fix: מאגר הקריאות של ה-MCP נגזר ממכסת הזיכרון של הקונטיינר, ולא ממספר המעבדים של המארח (#3391).** כלי הקריאה רצים ב-``asyncio.to_thread``, כלומר ב-executor ברירת המחדל של הלולאה, שגודלו ``min(32, os.cpu_count() + 4)`` — ו-``os.cpu_count()`` בקונטיינר הוא מספר הליבות של **המארח**, כפי ש-CPython מתעד במפורש. שורת הקיבולת מהייצור אמרה את זה במספרים: ``read pool 12 threads (os.cpu_count=8, usable=8)`` על מכסה של ``0.50 cpu``. לקריאת מונגו זה לא מזיק; לפרסור בפייתון טהור אין מקביליות ב-CPU בגלל ה-GIL, ולכן 12 חוטים היו 12 עצי פרסור בזיכרון בו-זמנית — יותר ממה שנכנס ב-512MiB — ורגע לפני שהפרסור הופך לכלי ציבורי. מעכשיו ``attach_read_pool`` מתקין בעליית ה-lifespan של אפליקציית ה-ASGI מאגר משלו, בגודל ``(מגבלת הזיכרון − קו בסיס − שוליים) ÷ עלות פרסור אחד``: המגבלה נקראת מ-cgroup בזמן ריצה (``memory.max`` ב-v2, ‏``memory.limit_in_bytes`` ב-v1) ולא מקובעת בקוד, קו הבסיס והשוליים הם מדידות מהשירות בייצור עם תאריך, ועלות הפרסור נגזרת מ-``MAX_FILE_SIZE_FOR_DISPLAY`` כי הכלי דוחה ``too_large`` לפני שהוא מפרסר. על תוכנית הייצור זה **10** חוטים במקום 12 — עלות הפרסור נמדדה מחדש על הפרסר הנעוץ (``scripts/measure_md_parse_cost.py``; הקבוע נמדד על ``services.md_parser`` כהכנה לכלי ה-Markdown של PR 5, בעוד הכלי הציבורי היום מפרסר RST ב-``services.rst_parser``, שעולה כ-6 בתים לבית גם על העמוד הצפוף ביותר — והסקריפט מודד את שניהם), והיא תלויה בצפיפות הטוקנים של המסמך ולא בגודלו: המסמך הצפוף ביותר בקורפוס הוא הקבוע, וצורה עוינת של שורות-תבליט בודדות עולה פי ארבעה ממנו — מה ששום רוחב מאגר אינו סופג, ומחכה לתקרת טוקנים בפרסר עצמו. **ובעקבות הסקירה:** ``codekeeper_docs_get_section`` מעביר לפרסר את תקרת הסקשנים של האאוטליין (50,000) ומחזיר ``too_many_sections`` במקום לפרסר את הקובץ כולו — צורה עוינת של RST (כותרת בת תו אחד בכל שורה) עלתה 44.0MiB לפרסור, יותר מ-35.2MiB שהמאגר מקצה לחוט, ועכשיו נעצרת ב-20.1MiB; אף עמוד אמיתי אינו מתקרב לתקרה. רצפה של 2 (קריאה תקועה אחת לא משביתה את השאר), תקרה של 12, הרוחב שהייצור כבר הריץ (מעבר לתוכנית גדולה לא מרחיב את המאגר בלי החלטה; הגרסה הראשונה נקבה ב-32, והסקירה של #3429 הורידה אותה כי המחלק מתאר את הפרסור ולא את קריאות הטווח של האדמין; חסימת הקריאה במקור — #3433), וכשאין מגבלה קריאה — הרצפה, **לא** ``cpu_count + 4``, עם WARNING שאומר שזה קרה. **ושורת הקיבולת מפסיקה לחשב בעצמה:** היא נרשמת מתוך ה-lifespan, קוראת את ``_max_workers`` של המאגר שהותקן בפועל, ומדווחת גם את מגבלת הזיכרון לצד מכסת ה-CPU — כי רשומה שמתארת מצב ומתעדכנת בנפרד ממנו היא בדיוק מה שמשקר בשקט. ה-seam הוא עטיפת ``router.lifespan_context``, אותו seam שניקוז ה-PostHog כבר משתמש בו, כי ``FastMCP.streamable_http_app()`` בונה את Starlette עם ``lifespan=`` משלה ו-``on_startup`` אינו רץ. הטסט המרכזי נכנס ל-lifespan של האפליקציה האמיתית ומוכיח שקריאת ``to_thread`` נוחתת על חוט של המאגר החדש — לא שפונקציה נקראה; שורת הקיבולת נבדקת בתת-תהליך נקי דרך הפלט האמיתי ולא דרך ``caplog``. ראו "מודל הריצה של הכלים" ב-:doc:`mcp-server`. - **feat: ``codekeeper_docs_get_section`` מוצא סעיף גם לפי המזהה שלו, ולא רק לפי השם המלא.** הסוכנים מפנים זה לזה לפי מזהה — "קרא K11", "ראו U3" — ועד היום ``section="K11"`` החזיר ``section_not_found`` עם רשימת הצעות **ריקה**, כי ההתאמה הייתה שוויון מלא ו-``difflib`` עם ``cutoff=0.5`` אינו מוצא קרבה בין שלושה תווים לכותרת עברית ארוכה. כלומר הזרימה המרכזית של הכלי לא עבדה. מעכשיו, **ורק אחרי שהשוויון המלא לא מצא כלום**, שאילתה שבנויה כמזהה (עד שלוש אותיות ואחריהן עד שלוש ספרות, עם נקודה אופציונלית) מותאמת לכותרת שנפתחת באותו מזהה. **והמזהה נקרא כיחידה ולא כקידומת של מחרוזת**, ולכן ``K1`` מחזיר את ``K1`` בלבד ולעולם לא את ``K10`` עד ``K15`` — הצורה השבורה של הבדיקה הזאת היא אותה מחלקה בדיוק שבה גבול נתיב נבדק כרצף תווים. שאילתה שאינה בנויה כמזהה אינה עוברת במסלול הזה כלל, ולכן ``section="איך"`` ממשיך להחזיר אפס התאמות גם בעמוד שיש בו חמש-עשרה כותרות שמתחילות במילה הזאת. מזהה שחוזר פעמיים הוא ``ambiguous_section`` עם מועמדים, כמו כל כותרת כפולה, ושאילתת מזהה שלא נמצאה מקבלת ב-``suggestions`` קודם כול כותרת קרובה, אם יש כזו בעמוד, **ורק כשאין אף כותרת קרובה** את המזהים שכן קיימים — עד 50, ועם ``suggestions_truncated`` כשהיו עוד. **והתקרה גבוהה בכוונה**, כי רשימת מזהים אינה מדורגת: לשאלה "אלה שכן קיימים" אין תשובה "החמש הטובות", וחיתוך שלה מסתיר פריטים בלי קריטריון ובלי דרך לבקש את השאר. **והדגל הזה אומר את אותו דבר בשני המסלולים**: גם רשימת מזהים שנחתכה וגם רשימת כותרות קרובות שנחתכה מדליקות אותו. שדה בשם ``truncated`` שמשמעותו "נחתך" במסלול אחד ו"שקט" בשני הוא בדיוק ההפתעה שאנחנו מנסים למנוע. **ונקודה עוד יותר קלה לפספס: הנקודה שפותחת תת-מספור אינה גבול.** בכותרת ``K11. טקסט`` הנקודה **מסיימת** את המזהה, ובכותרת ``K11.1 טקסט`` היא **מפרידה** בתוך מזהה ארוך יותר — ולכן ``K11`` לעולם אינו מחזיר את ``K11.1``, וכותרת בתת-מספור נגישה בשמה המלא בלבד. זו אותה מחלקה בדיוק כמו ``K1`` שתופס את ``K10``: גבול שנבדק כתו בודד, בלי לשאול מה בא אחריו. **ההתנהגות על התיעוד כאן לא זזה, וזה נמדד ולא הונח:** הפלט של הכלי על כל קובצי ה-RST — כל כותרת, בכל צורותיה — זהה בית-בית לפני ואחרי. וההנחה שהכול נשען עליה, שאף כותרת בעמודים כאן אינה נפתחת במזהה, **אינה ספירה שנרשמה פעם אחת אלא טסט שרץ בכל CI**: ``tests/test_docs_headings_carry_no_identifier.py`` סורק את כל העמודים ונופל על כותרת שנפתחת במזהה, עם דוגמת autodoc אמיתית שמוכיחה שהוא מסוגל ליפול. **ומה שלא נבדק בקורפוס הזה נבדק בבקרה סינתטית**, כי סוללה שכל תשובותיה "לא נמצא" אינה מבדילה בין ענף כבוי לבין פרובים עיוורים. שני הדברים שמפתיעים קורא — קיצור המזהה, וכותרת שנושאת בקטיקים ולכן דורשת אותם בשאילתה — עברו לתיאור הפרמטר ``section``, שאינו נחתך אצל הלקוח כמו תיאור הכלי. ראו :ref:`mcp-docs-section`. diff --git a/mcp_server/server.py b/mcp_server/server.py index ee9094438..2f82c0724 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -803,6 +803,28 @@ def _read_pool_size(memory_limit: int | None) -> _ReadPoolSizing: return _ReadPoolSizing(int(allowed), "memory", f"sized from memory: {arithmetic}") +def _installed_width(pool: ThreadPoolExecutor) -> int | None: + """The width the executor actually has — or ``None`` when this Python no longer exposes it. + + ``ThreadPoolExecutor._max_workers`` is private to CPython, and it is read + on purpose (``state-record-without-state-change``: the capacity line + describes the pool that exists, not the one that was requested). Private + means it can go away. When it does, the answer here is ``None`` — not the + requested width, which would be exactly the echo the line exists to avoid + — and the callers print that they do not know, with a warning, instead of + failing the startup of the service over a log line (#3433, SUGG-004). + """ + return getattr(pool, "_max_workers", None) + + +def _width_label(pool: ThreadPoolExecutor, sizing: _ReadPoolSizing) -> str: + """What the capacity line prints for the read pool: the installed width, or an honest "unreadable".""" + width = _installed_width(pool) + if width is not None: + return str(width) + return f"{sizing.workers} (requested; installed width unreadable)" + + def _log_dispatch_capacity( read_pool: ThreadPoolExecutor, memory_display: str, sizing: _ReadPoolSizing ) -> None: @@ -821,7 +843,10 @@ def _log_dispatch_capacity( number the pool is sized from. Every value is computed before the call rather than inside it, so a level - guard or a deleted line takes the line and nothing else with it. + guard or a deleted line takes the line and nothing else with it. And the + private attribute is read through :func:`_installed_width`: should it + vanish, the line says "unreadable" beside the requested width and a + warning names the reason — a log line never fails the startup. """ detected = os.cpu_count() or 1 try: @@ -829,10 +854,17 @@ def _log_dispatch_capacity( except (AttributeError, OSError): usable = detected quota = _cpu_budget() + if _installed_width(read_pool) is None: + logger.warning( + "mcp read pool width is not readable on this Python " + "(ThreadPoolExecutor._max_workers is gone); the capacity line reports " + "the requested width %d instead of the installed one", + sizing.workers, + ) logger.info( - "mcp dispatch capacity: read pool %d threads (%s; os.cpu_count=%d, " + "mcp dispatch capacity: read pool %s threads (%s; os.cpu_count=%d, " "usable=%d), write pool 1 thread, cpu quota %s, memory limit %s", - read_pool._max_workers, + _width_label(read_pool, sizing), sizing.detail, detected, usable, @@ -904,9 +936,9 @@ async def _lifespan_with_read_pool(scope_app: Any): # path nobody reports is the one that stays. The capacity line says # the size; this says that it was not chosen. logger.warning( - "mcp read pool fell back to %d threads: memory limit %s — reads are " + "mcp read pool fell back to %s threads: memory limit %s — reads are " "narrower than the plan allows until the cgroup limit is readable", - pool._max_workers, + _width_label(pool, sizing), memory_display, ) async with original(scope_app) as state: diff --git a/scripts/measure_md_parse_cost.py b/scripts/measure_md_parse_cost.py index f064db5d2..5a208c5ed 100644 --- a/scripts/measure_md_parse_cost.py +++ b/scripts/measure_md_parse_cost.py @@ -160,7 +160,7 @@ def density(text: str, parser: str = "md") -> float: if parser == "md": from services import md_parser - return len(md_parser._build_parser().parse(text)) * 1024 / size + return md_parser.token_count(text) * 1024 / size from services import rst_parser return len(rst_parser.parse_document(text).sections) * 1024 / size diff --git a/services/md_parser.py b/services/md_parser.py index cf5ca530d..90578afa9 100644 --- a/services/md_parser.py +++ b/services/md_parser.py @@ -318,6 +318,25 @@ def _build_parser() -> MarkdownIt: _MD = _build_parser() +def token_count(text: str) -> int: + """מספר הטוקנים שהפרסר של הכלי מייצר לטקסט — מדד צפיפות, לא מפה. + + קיים בשביל ``scripts/measure_md_parse_cost.py``, שמדרג מסמכים לפי + צפיפות טוקני-בלוק ל-KB, וקרא עד #3433 ל-``_build_parser`` הפרטית — + תלות סמויה בפנימי של המודול, ובנייה של פרסר חדש לכל קובץ. הפונקציה + הזאת היא התלות בגלוי: על ``_MD``, המופע שהכלי מריץ, בלי ``env`` ולכן + בלי תקרה (``_ceiling_rule`` מקבל ``None`` ומחזיר ``False``), ובלי + טוקני inline (``disable("inline")``) — כלומר בדיוק מה שהפרסור האמיתי + סופר. + + **ומחוץ ל-``__all__`` בכוונה:** הרשימה שם היא החוזה של "שני פארסרים + בני-החלפה", וטסט מקבע את ההפרש בינה לבין זו של ``rst_parser``. מדד + למדידה אינו חלק מהחוזה הזה ואינו קיים ל-RST, ולכן הוא ציבורי (בלי קו + תחתון, מתועד) אך אינו מיוצא. + """ + return len(_MD.parse(text)) + + def parse_document(text: str, *, max_sections: Optional[int] = MAX_SECTIONS) -> Document: """בונה ``Document`` מטקסט Markdown. diff --git a/tests/test_mcp_to_thread.py b/tests/test_mcp_to_thread.py index 74d9e50b1..1bb90fbae 100644 --- a/tests/test_mcp_to_thread.py +++ b/tests/test_mcp_to_thread.py @@ -962,6 +962,29 @@ def test_startup_names_the_read_pool_the_cpu_quota_and_the_memory_limit(caplog): assert expected in line, (expected, line) +def test_a_pool_without_a_readable_width_does_not_fail_startup_and_says_so(caplog): + """``_max_workers`` is CPython-private; if it goes, the line says so instead of the service not starting. + + A record that describes state reads the state — and when the state cannot + be read, it says *that*, rather than echoing the request as if it were + the state. The pool here is an object with no ``_max_workers`` at all, + which is what a future Python would look like to this code (#3433, + SUGG-004). + """ + import logging as _logging + import types as _types + + from mcp_server.server import _log_dispatch_capacity, _read_pool_size + + with caplog.at_level(_logging.INFO, logger="mcp_server.server"): + _log_dispatch_capacity(_types.SimpleNamespace(), "cgroup v2: 512.0MiB", _read_pool_size(512 * _MiB)) + + info = [r.getMessage() for r in caplog.records if r.levelno == _logging.INFO] + warnings = [r.getMessage() for r in caplog.records if r.levelno == _logging.WARNING] + assert any("read pool 10 (requested; installed width unreadable) threads" in m for m in info), info + assert any("_max_workers" in m for m in warnings), warnings + + def test_the_pool_is_sized_from_the_memory_budget(): """``(limit - baseline - margin) // cost of one parse`` — and a different limit gives a different answer. @@ -1009,20 +1032,25 @@ def test_the_pool_never_rises_above_the_cap(): assert sizing.detail.startswith("cap 12:"), sizing.detail -def test_no_readable_memory_limit_falls_back_to_the_floor_and_not_to_cpu_count(): +def test_no_readable_memory_limit_falls_back_to_the_floor_and_not_to_cpu_count(monkeypatch): """The fallback is the conservative constant, and it is not ``cpu_count + 4``. ``cpu_count + 4`` is exactly the number this sizing exists to stop relying - on, so the test pins the fallback to the floor on any machine — on a - 16-core box the old default would have given 20. + on. ``os.cpu_count`` is pinned to 16 here so that the old default, + ``min(32, 16 + 4) = 20``, is a number the floor can never be — a fallback + that consulted the host's cores would answer 20 and fail. The line this + replaced (``workers != min(32, cpu_count + 4) or workers == 2``) could not + fail after the assertion before it had pinned ``workers == 2`` (#3433, + SUGG-001). """ import os from mcp_server.server import _READ_POOL_FLOOR, _read_pool_size + monkeypatch.setattr(os, "cpu_count", lambda: 16) + sizing = _read_pool_size(None) assert (sizing.workers, sizing.source) == (_READ_POOL_FLOOR, "fallback"), sizing - assert sizing.workers != min(32, (os.cpu_count() or 1) + 4) or sizing.workers == 2 assert "no memory limit readable" in sizing.detail, sizing.detail @@ -1100,6 +1128,42 @@ def test_the_memory_limit_is_read_from_cgroup_v1_when_v2_is_absent(tmp_path, mon assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") +def test_an_empty_cgroup_v2_file_falls_through_to_v1(tmp_path, monkeypatch): + """A ``memory.max`` that exists but holds nothing is not a limit of zero — v1 answers. + + ``split()`` of an empty file is ``[]`` and ``raw[0]`` is the ``IndexError`` + the reader's ``except`` names; until now only a *missing* v2 file proved + the fall-through (#3433, SUGG-014). Line coverage was complete, the + scenario was not. + """ + import mcp_server.server as server_module + + _fake_cgroup( + monkeypatch, + tmp_path, + { + "/sys/fs/cgroup/memory.max": "", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "268435456\n", + }, + ) + assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") + + +def test_a_non_numeric_cgroup_v2_file_falls_through_to_v1(tmp_path, monkeypatch): + """``memory.max`` with a word that is not ``max`` is the ``ValueError`` branch — v1 answers.""" + import mcp_server.server as server_module + + _fake_cgroup( + monkeypatch, + tmp_path, + { + "/sys/fs/cgroup/memory.max": "lots\n", + "/sys/fs/cgroup/memory/memory.limit_in_bytes": "268435456\n", + }, + ) + assert server_module._memory_limit() == (268435456, "cgroup v1: 256.0MiB") + + def test_the_cgroup_v1_unlimited_sentinel_is_no_limit(tmp_path, monkeypatch): """v1 has no word for unlimited: it reads back ``LONG_MAX`` rounded to a page. diff --git a/tests/test_md_parser.py b/tests/test_md_parser.py index ef351ec15..3b28fe0f2 100644 --- a/tests/test_md_parser.py +++ b/tests/test_md_parser.py @@ -430,6 +430,19 @@ def spy(self, chainName=""): ) +def test_token_count_is_the_tool_parsers_own_count_and_not_an_export(): + """המדד למדידה סופר על ``_MD`` — המופע שהכלי מריץ — ונשאר מחוץ לחוזה של ``__all__``. + + הסקריפט שמודד את עלות הפרסור קרא ל-``_build_parser`` הפרטית ובנה פרסר + חדש לכל קובץ; ``token_count`` היא אותה תלות בגלוי (#3433, SUGG-010). + """ + text = "---\na: 1\n---\n\n# כותרת\n\n> ## בציטוט\n\n- פריט\n\n| a | b |\n|---|---|\n| 1 | 2 |\n" + + assert md_parser.token_count(text) == len(md_parser._MD.parse(text)) + assert md_parser.token_count("") == 0 + assert "token_count" not in md_parser.__all__ + + def test_the_ceiling_anchor_fails_loudly_when_the_plugin_is_not_registered(): """העוגן ``before("front_matter", ...)`` קונה כשל מיידי — ועל מה בדיוק. diff --git a/tests/test_measure_md_parse_cost_script.py b/tests/test_measure_md_parse_cost_script.py index a2320c987..9137fc386 100644 --- a/tests/test_measure_md_parse_cost_script.py +++ b/tests/test_measure_md_parse_cost_script.py @@ -128,6 +128,19 @@ def test_tiling_stops_at_the_ceiling_and_is_longer_than_its_input(): assert 997 <= len(script.tiled_to_ceiling(text, 1000).encode("utf-8")) <= 1000 +def test_density_goes_through_the_public_token_count_and_not_the_private_builder(monkeypatch): + """הסקריפט אינו קורא ל-``md_parser._build_parser`` (#3433, SUGG-010): התלות היא ``token_count``.""" + from services import md_parser + + script = _load_script() + + def _forbidden(): + raise AssertionError("הסקריפט קרא ל-_build_parser הפרטית") + + monkeypatch.setattr(md_parser, "_build_parser", _forbidden) + assert script.density("- a\n" * 10, "md") > 0 + + def test_density_ranks_a_dense_document_above_a_sparse_one_of_the_same_size(): """הצפיפות היא מה שמדרג: טוקני בלוק ל-KB ב-Markdown, סקשנים ל-KB ב-RST.""" script = _load_script()