unsloth/tests/python/test_windows_setup_output_encoding.py
Daniel Han 827d25931b
Stop 19 test files racing on one PowerShell startup cache (#9371)
* Stop 19 test files racing on one PowerShell startup cache

Backend CI run 32341628757 on `1c3dde199` finished `284 failed, 8498 passed`. Every
one of the 284 was a pwsh subprocess ending `died with <Signals.SIGABRT: 6>`, across
19 files that all read as Windows-installer regressions. None of them were. 222 of
the aborts land inside a two-second window, 88 at 07:09:30 and 133 at 07:09:31,
which is a mass kill of every live pwsh rather than independent per-test flakiness.

The cause
------------------------------------------------------------------------
Every `-NonInteractive` startup reads and rewrites an ~83 KB
`$XDG_CACHE_HOME/powershell/StartupProfileData-NonInteractive`, and XDG_CACHE_HOME
defaults to `$HOME/.cache`. Under `-n 4` all four xdist workers share one HOME, so
the whole job's pwsh processes race on one file and a startup that deserialises a
half-written one dies before it reaches our script. `Stack overflow.` is .NET's
failfast, which cannot unwind a blown stack, so it prints one line and calls
abort(); that is the SIGABRT (PowerShell/PowerShell#24461).

Measured twice, independently, 4000 startups per arm:

  run 1  shared cache dir     7/4000 died  {-11: 3, -6: 4}
         private cache dirs   0/4000
  run 2  shared cache dir    11/4000 died  {-11: 10, -6: 1}
         private cache dirs   0/4000

Three distinct crash shapes appeared, and each names the torn file rather than our
scripts: `Stack overflow.`, `System.IO.FileLoadException: The given assembly name`,
and `System.ArgumentException: String cannot have zero length.` 18 deaths in 8000
shared startups, 0 in 8000 private.

CI agrees from the other direction. Of the pwsh-heavy files in that run, exactly one
had zero failures, tests/test_windows_amd_gpu_scan_fallback.py, and it is the only
one that hands its child a private HOME, across roughly 80 startups where the run's
own rate predicts about 16 failures.

What is NOT established
------------------------------------------------------------------------
Neither experiment reproduces CI's rate. Roughly 20% of pwsh startups died there
against 0.2 to 0.3% here, and at CI's actual `-n 4` on this box I measured 0/1200 in
both arms: the race needed 48-way concurrency before it appeared at all. The likely
reason is that four workers on a 4-core runner are in real contention while four
threads on a 192-core box almost never overlap in the critical section, but that is
reasoning and not a measurement, so treat the mechanism as proven and the magnitude
as unexplained. That is also why this does not stop at removing the shared file.

Three layers, in order
------------------------------------------------------------------------
1. Remove the contended resource. One cache directory per xdist worker, fresh per
   session. Workers run their tests one at a time, so within a worker the startups
   stay sequential and the cache still does its job warm; across workers the
   directories are disjoint and there is nothing left to race on. Fresh rather than a
   stable path, because a cache torn by an earlier run would otherwise poison every
   later session on the same box.
2. Retry a run that produced no verdict. Three attempts, unslept, because the trigger
   is process startup rather than a resource that frees up.
3. Attribute what is left. A crash raises PwshInterpreterCrash naming the interpreter.

Layer 1 is the fix; 2 and 3 exist because of the unexplained magnitude above.

Deliberately NOT done: bounding pwsh concurrency with a lock, or giving up `-n 4`.
The workflow records 806.1s to 219.7s from that flag, and the contended resource can
be removed rather than rationed.

The rule that keeps this honest
------------------------------------------------------------------------
A signal is not a verdict, so retrying it papers over nothing: the script never ran
to its end. A normal exit is returned untouched on the first attempt whatever its
code, so a pwsh that runs and gives the WRONG answer still fails with its own
message. Getting that second half wrong would turn this into a way to retry real
regressions into green, which is worse than the bug it fixes, so both directions are
executed in tests/studio/test_pwsh_interpreter_crash_attribution.py against a real
SIGABRT rather than reviewed.

Mutation-tested: relaxing the crash test from `returncode < 0` to `returncode != 0`
fails test_a_clean_run_with_the_wrong_answer_still_fails_with_its_own_message and
test_a_clean_run_is_not_retried, which are exactly the two that guard that direction.

This also generalises `_run_pwsh` from tests/studio/test_install_phase_timing.py,
added earlier today for a second, signal-free shape: pwsh printing its "The
PowerShell process will exit" banner and exiting normally with empty stdout. That one
cannot be seen in the exit status, so it stays a text match.

Verified
------------------------------------------------------------------------
tests/python/test_windows_xformers_installer.py, tests/studio/test_install_phase_timing.py,
tests/studio/install/ and the new guard: 2635 passed, 3 skipped.
Guard alone: 5 passed.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Drop the subprocess import the pwsh conversion left behind

Source lint's import-hoist check is right: every subprocess.run in
test_windows_xformers_installer.py became run_pwsh, so `import subprocess` has no
references left except the one inside a comment explaining why run_pwsh is used
instead. Its wording names the shape exactly -- "was used before, now unused
(references re-pointed)" -- which is what a mechanical call-site rewrite leaves
behind.

Swept the other 18 converted files the same way with an AST pass rather than by
eye. This was the only real one: the remaining hits are `from __future__ import
annotations`, which every such scan reports, and a PropertyMock in
test_rocm_support.py that is present on main unchanged.

42 passed.

* Suppress the core dump on the forged SIGABRT

tests/test_deliberate_crashes_suppress_cores.py caught this: the abort child had no
PR_SET_DUMPABLE=0, so each of these aborts piped a multi-MB core to apport before the
child could be reaped. The guard is right and its message names the fix.

The child still exits -6 and PR_GET_DUMPABLE reads 0, so all five verdicts are
unchanged. Linux-only and non-fatal elsewhere: Windows has no CDLL(None) and pipes no
core, so arming it there would trade a no-op for a lost test.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: danielhanchen <unslothai@gmail.com>
2026-08-20 04:22:41 -07:00

808 lines
36 KiB
Python
Raw Blame History

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""The Windows desktop setup log must be UTF-8 and must print each step once.
"Getting things ready..." used to produce::
?? Unsloth Studio Setup
????????????????????????????????????????????????????
gpu
none (chat-only / GGUF)
gpu none (chat-only / GGUF)
Encoding: 5.1 encodes redirected output with the OEM code page while the desktop
app decodes the pipe as UTF-8 (``from_utf8_lossy``, src-tauri/src/install.rs).
U+1F9A5 has no OEM form so PowerShell writes one ``?`` per UTF-16 surrogate;
U+2500 has one, so it becomes a bare 0xC4 and arrives as U+FFFD.
Duplication: ``step``/``substep`` wrote through Write-Host *and* a console-handle
mirror, and the CLI spawns setup.ps1 as ``-Command "& '...' *>&1"``, which merges
the Information stream into stdout.
Splitting: ``step`` built one line from two Write-Host calls with -NoNewline,
which a redirected consumer splits at the record boundary.
Sink: fixing ``step``/``substep`` left every other line on Write-Host, which
5.1's console host writes through its own console-attached writer rather than
the UTF-8 one bound to ``[Console]::Out``. The banner and the footer are not
steps, so they never entered the sink #8083 built. Both entry scripts now
funnel through ``Write-StudioLine``, and Write-Host survives only inside
helpers that have already ruled out the redirected sink.
No console: the transcode above needs a console to transcode against. Where
``CREATE_NO_WINDOW`` really leaves the child without one, which is the state
install.rs's own comment assumes, Write-Host has no screen buffer to query and
throws instead, taking the whole script down under ``-ErrorActionPreference
Stop``. The banner is then not mangled, it is absent. That is what
``test_banner_survives_a_console_less_spawn`` measures, and it is the only case
here that separates this fix from what shipped before it.
The byte-level tests assert on raw bytes; decoding first would hide the exact
regression being guarded.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
import uuid
from functools import lru_cache
from pathlib import Path
import pytest
from unsloth_pwsh_runner import run_pwsh
REPO_ROOT = Path(__file__).resolve().parents[2]
SETUP_PS1 = REPO_ROOT / "studio" / "setup.ps1"
INSTALL_PS1 = REPO_ROOT / "install.ps1"
EXTRACTOR = REPO_ROOT / "tests" / "studio_setup_ps1" / "Get-FunctionSource.ps1"
SLOTH = "\U0001f9a5"
RULE_CHAR = ""
REPLACEMENT = "<EFBFBD>"
# The desktop app spawns Windows PowerShell 5.1; pwsh stands in elsewhere. The
# OEM-code-page bug only reproduces on 5.1, which the Windows runner covers.
_PWSH = shutil.which("powershell") if sys.platform == "win32" else shutil.which("pwsh")
pwsh_only = pytest.mark.skipif(_PWSH is None, reason = "PowerShell is unavailable")
def _harness(redirected_probe: bool) -> str:
"""Emit a known banner + steps using the real helpers.
Extracted from setup.ps1 rather than restated, so a change in shape fails
here instead of drifting.
"""
sink = "$true" if redirected_probe else "[Console]::IsOutputRedirected"
return f"""
$ErrorActionPreference = 'Stop'
$_UnslothUtf8NoBom = New-Object System.Text.UTF8Encoding $false
try {{ [Console]::OutputEncoding = $_UnslothUtf8NoBom }} catch {{ }}
$OutputEncoding = $_UnslothUtf8NoBom
. '{EXTRACTOR.as_posix()}'
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioStdoutMirror', 'step', 'substep')) {{
$src = Get-FunctionSource -Path '{SETUP_PS1.as_posix()}' -Name $fn
if (-not $src) {{ throw "missing $fn" }}
. ([scriptblock]::Create($src))
}}
$script:StudioVtOk = $false
$script:StudioStdoutRedirected = {sink}
$Rule = [string]::new([char]0x2500, 52)
$Sloth = [char]::ConvertFromUtf32(0x1F9A5)
if ($script:StudioStdoutRedirected) {{
[Console]::Out.WriteLine(" $Sloth Unsloth Studio Setup")
[Console]::Out.WriteLine(" $Rule")
[Console]::Out.Flush()
}} else {{
Write-Host (" " + $Sloth + " Unsloth Studio Setup")
Write-Host (" " + $Rule)
}}
step "gpu" "none (chat-only / GGUF)"
step "long paths" "enabled"
substep "installing OXC validator runtime..."
"""
def _section(source: str, title: str) -> str:
"""The statements under a ``# <title>`` box header, up to the blank line.
Sliced out of setup.ps1 rather than restated, so the banner and the footer
are exercised as written. A rewrite that drops them back onto Write-Host
fails here.
"""
match = re.search(rf"(?m)^# {re.escape(title)}\n#[^\n]*\n", source)
assert match, f"no '{title}' section header in {SETUP_PS1.name}"
body = source[match.end() :]
return body[: body.index("\n\n")]
def _banner_footer_harness() -> str:
"""Print setup.ps1's real banner and footer with the redirected sink on."""
source = SETUP_PS1.read_text(encoding = "utf-8")
return f"""
$ErrorActionPreference = 'Stop'
$_UnslothUtf8NoBom = New-Object System.Text.UTF8Encoding $false
try {{ [Console]::OutputEncoding = $_UnslothUtf8NoBom }} catch {{ }}
$OutputEncoding = $_UnslothUtf8NoBom
. '{EXTRACTOR.as_posix()}'
foreach ($fn in @('Get-StudioAnsi', 'Write-StudioLine', 'Write-StudioStdoutMirror', 'step', 'substep')) {{
$src = Get-FunctionSource -Path '{SETUP_PS1.as_posix()}' -Name $fn
if (-not $src) {{ throw "missing $fn" }}
. ([scriptblock]::Create($src))
}}
# No console handle under CREATE_NO_WINDOW, so the real run never takes the
# ANSI branch; pin it here instead of depending on the test host.
$script:StudioVtOk = $false
$script:StudioStdoutRedirected = $true
$script:LlamaCppDegraded = $false
$env:SKIP_STUDIO_BASE = '1'
$Rule = [string]::new([char]0x2500, 52)
{_section(source, "Banner")}
{_section(source, "Footer")}
"""
def _run_capturing_bytes(
script: str,
use_command_shape: bool,
stem: str = "setup_output",
) -> bytes:
"""Run through a real pipe, in both launch shapes the product uses.
``-File`` is how the desktop app spawns the installer; ``-Command ... *>&1``
is how the CLI spawns setup for ``unsloth studio update``. Piped stdout is
required to reproduce, and is captured as bytes, never decoded here.
"""
# Unique per call. The name used to be (stem, shape), which several tests
# share, so under pytest-xdist one case could unlink the script after another
# had written it and before its pwsh child opened it. pwsh is installed on
# ubuntu-latest, so these do not skip there and would race for real.
tmp = (
REPO_ROOT
/ "tests"
/ "python"
/ f"_{stem}_probe_{int(use_command_shape)}_{uuid.uuid4().hex}.ps1"
)
tmp.write_text(script, encoding = "utf-8")
try:
base = [_PWSH, "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass"]
if use_command_shape:
literal = str(tmp).replace("'", "''")
argv = base + ["-Command", f"& '{literal}' *>&1"]
else:
argv = base + ["-File", str(tmp)]
# run_pwsh, not subprocess.run: the byte-level cases read this stdout as the setup
# log, and an interpreter that aborted leaves an empty or truncated stream, which
# reads as the banner being mangled or lost. See tests/_shared/unsloth_pwsh_runner.py.
proc = run_pwsh(argv, stdout = subprocess.PIPE, stderr = subprocess.PIPE, timeout = 180)
assert proc.returncode == 0, proc.stderr.decode("utf-8", errors = "replace")
return proc.stdout
finally:
tmp.unlink(missing_ok = True)
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_setup_output_is_valid_utf8(use_command_shape: bool) -> None:
"""Strict decode. Lossy decoding here would hide the exact regression."""
raw = _run_capturing_bytes(_harness(redirected_probe = True), use_command_shape)
text = raw.decode("utf-8") # strict on purpose; UnicodeDecodeError is the failure
assert REPLACEMENT not in text, "output contains U+FFFD (OEM bytes decoded as UTF-8)"
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_banner_glyphs_survive_the_pipe(use_command_shape: bool) -> None:
raw = _run_capturing_bytes(_harness(redirected_probe = True), use_command_shape)
text = raw.decode("utf-8")
assert text.count(SLOTH) == 1, "sloth emoji lost or duplicated"
assert "??" not in text, "emoji was transcoded to '?' by a non-UTF-8 code page"
assert RULE_CHAR * 52 in text, "the 52-char rule did not survive intact"
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_every_step_appears_exactly_once(use_command_shape: bool) -> None:
raw = _run_capturing_bytes(_harness(redirected_probe = True), use_command_shape)
text = raw.decode("utf-8")
for sentinel in ("none (chat-only / GGUF)", "enabled", "installing OXC validator runtime..."):
assert (
text.count(sentinel) == 1
), f"{sentinel!r} appeared {text.count(sentinel)} times, expected 1"
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_step_label_and_value_stay_on_one_line(use_command_shape: bool) -> None:
"""The `gpu` / newline / `none (chat-only / GGUF)` split."""
raw = _run_capturing_bytes(_harness(redirected_probe = True), use_command_shape)
lines = raw.decode("utf-8").splitlines()
matches = [line for line in lines if "gpu" in line]
assert len(matches) == 1, f"expected one gpu line, got {matches!r}"
assert matches[0] == " gpu none (chat-only / GGUF)"
# The banner and the footer are the two blocks a user actually reads in the
# desktop setup log, and neither goes through step/substep, so they need their
# own byte-level coverage.
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_banner_and_footer_are_valid_utf8(use_command_shape: bool) -> None:
raw = _run_capturing_bytes(_banner_footer_harness(), use_command_shape, stem = "banner_footer")
text = raw.decode("utf-8") # strict on purpose
assert REPLACEMENT not in text, "banner/footer contain U+FFFD (OEM bytes decoded as UTF-8)"
assert "??" not in text, "emoji was transcoded to '?' by a non-UTF-8 code page"
@pwsh_only
@pytest.mark.parametrize("use_command_shape", [False, True], ids = ["-File", "-Command-merged"])
def test_banner_and_footer_print_once_each(use_command_shape: bool) -> None:
"""One sink, so no line survives twice even when *>&1 merges the streams."""
raw = _run_capturing_bytes(_banner_footer_harness(), use_command_shape, stem = "banner_footer")
text = raw.decode("utf-8")
assert text.count(SLOTH) == 1, "sloth emoji lost or duplicated"
assert text.count(f" {SLOTH} Unsloth Studio Setup") == 1
assert text.count(" Unsloth Studio Setup Complete") == 1
# One rule under the banner, two around the footer.
assert text.count(" " + RULE_CHAR * 52) == 3, "the 52-char rule did not survive intact"
# Source contracts. These run everywhere, including the Linux backend CI job,
# so a regression is caught without waiting for a Windows runner.
def _strip_comments(source: str) -> str:
return re.sub(r"(?m)#.*$", "", source)
def _mask_literals(source: str) -> str:
"""Blank comments and string literals, keeping every offset in place.
A regex over the raw text would trip over the launcher script install.ps1
builds in a here-string (it has its own Write-Host and no helper to call)
and over the commented-out block in setup.ps1.
"""
out = list(source)
index, size = 0, len(source)
def blank(start: int, stop: int) -> None:
for k in range(start, stop):
if out[k] != "\n":
out[k] = " "
while index < size:
char = source[index]
if source.startswith("<#", index):
stop = source.find("#>", index + 2)
stop = size if stop < 0 else stop + 2
elif char == "@" and index + 1 < size and source[index + 1] in "\"'":
terminator = "\n" + source[index + 1] + "@"
stop = source.find(terminator, index + 2)
stop = size if stop < 0 else stop + len(terminator)
elif char == "#" and (index == 0 or source[index - 1] in " \t\r\n(){};,|=&"):
stop = source.find("\n", index)
stop = size if stop < 0 else stop
elif char in "\"'":
stop = index + 1
while stop < size:
if char == '"' and source[stop] == "`":
stop += 2
continue
if source[stop] == char:
if stop + 1 < size and source[stop + 1] == char:
stop += 2
continue
stop += 1
break
stop += 1
else:
index += 1
continue
blank(index, stop)
index = stop
return "".join(out)
def _close_brace(masked: str, open_offset: int) -> int:
"""Offset of the `}` closing the `{` at `open_offset`, over masked source."""
depth = 0
for offset in range(open_offset, len(masked)):
if masked[offset] == "{":
depth += 1
elif masked[offset] == "}":
depth -= 1
if depth == 0:
return offset
raise AssertionError("unbalanced braces")
def _function_span(masked: str, name: str) -> tuple[int, int]:
"""Offsets of `function <name> { ... }`, brace-matched over masked source."""
match = _function_match(masked, name)
assert match, f"no function {name}"
return match.start(), _close_brace(masked, masked.index("{", match.end())) + 1
def _function_match(masked: str, name: str) -> re.Match[str] | None:
return re.search(r"(?im)^[ \t]*function\s+" + re.escape(name) + r"\b", masked)
# Write-Host may only appear inside a helper that has already ruled out the
# redirected sink: Write-StudioLine itself, and setup.ps1's step/substep, which
# return through the console mirror before reaching their interactive branch.
WRITE_HOST_ALLOW_LIST = {
SETUP_PS1: ("Write-StudioLine", "step", "substep"),
INSTALL_PS1: ("Write-StudioLine",),
}
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_entry_scripts_set_the_utf8_invariant(path: Path) -> None:
source = path.read_text(encoding = "utf-8")
assert "[Console]::OutputEncoding = $_UnslothUtf8NoBom" in source
assert "$env:PYTHONUTF8 = '1'" in source
assert "$env:PYTHONIOENCODING = 'utf-8'" in source
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_entry_scripts_have_no_bom(path: Path) -> None:
"""5.1 parses BOM-less scripts as ANSI, so the fix stays ASCII-only.
A BOM would be a far wider packaging change: these get concatenated and
streamed through `irm | iex`.
"""
assert not path.read_bytes().startswith(b"\xef\xbb\xbf")
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_step_helper_emits_one_record(path: Path) -> None:
"""-NoNewline splits a logical line once a redirected consumer sees records."""
source = _strip_comments(path.read_text(encoding = "utf-8"))
match = re.search(r"(?m)^\s*function step\b", source)
assert match, f"no step function in {path.name}"
body = source[match.start() : match.start() + 2000]
assert "-NoNewline" not in body
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_write_host_only_survives_inside_gated_helpers(path: Path) -> None:
source = path.read_text(encoding = "utf-8")
masked = _mask_literals(source)
spans = [_function_span(masked, name) for name in WRITE_HOST_ALLOW_LIST[path]]
lines = source.splitlines()
stray = [
f" {path.name}:{source.count(chr(10), 0, m.start()) + 1}: "
f"{lines[source.count(chr(10), 0, m.start())].strip()}"
for m in re.finditer(r"\bWrite-Host\b", masked)
if not any(lo <= m.start() < hi for lo, hi in spans)
]
assert not stray, (
"Write-Host is written by 5.1's console host, not by the UTF-8 writer bound to "
"[Console]::Out, so under CREATE_NO_WINDOW the desktop app renders these lines as "
"U+FFFD. Call Write-StudioLine instead (same arguments, same colors when "
"interactive):\n" + "\n".join(stray)
)
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_every_allow_listed_helper_rules_out_the_redirected_sink(path: Path) -> None:
"""The allow-list is only safe while each entry still checks the sink."""
masked = _mask_literals(path.read_text(encoding = "utf-8"))
for name in WRITE_HOST_ALLOW_LIST[path]:
lo, hi = _function_span(masked, name)
assert "$script:StudioStdoutRedirected" in masked[lo:hi], (
f"{name} in {path.name} reaches Write-Host without testing "
"$script:StudioStdoutRedirected; drop it from the allow-list or gate it"
)
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_the_sink_helper_is_defined_before_the_first_line_it_prints(path: Path) -> None:
"""PowerShell resolves functions at call time, but not before their line runs."""
masked = _mask_literals(path.read_text(encoding = "utf-8"))
definition = masked.index("function Write-StudioLine")
first_call = min(m.start() for m in re.finditer(r"\bWrite-StudioLine\b", masked))
assert first_call == definition + len(
"function "
), f"{path.name} calls Write-StudioLine before defining it"
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_the_sink_helper_writes_through_the_console_handle(path: Path) -> None:
masked = _mask_literals(path.read_text(encoding = "utf-8"))
lo, hi = _function_span(masked, "Write-StudioLine")
body = path.read_text(encoding = "utf-8")[lo:hi]
assert "[Console]::Out.WriteLine($Message)" in body
# Tauri reads line by line, so a buffered line is a line the user never sees.
assert "[Console]::Out.Flush()" in body
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_entry_scripts_resolve_the_redirect_sink_once(path: Path) -> None:
source = path.read_text(encoding = "utf-8")
assert "$script:StudioStdoutRedirected = [Console]::IsOutputRedirected" in source
def test_refresh_environment_cannot_clobber_the_python_encoding_vars() -> None:
"""Refresh-Environment reloads the registry repeatedly through a long run.
Without the guard a registry PYTHONUTF8=0 reloads over ours and every later
Python child goes back to mojibake.
"""
source = SETUP_PS1.read_text(encoding = "utf-8")
assert "$key -eq 'PYTHONUTF8' -or $key -eq 'PYTHONIOENCODING'" in source
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_entry_scripts_bind_a_utf8_writer_when_there_is_no_console(path: Path) -> None:
"""The setter needs a console handle, and the desktop spawns us without one.
It drops the cached writer BEFORE throwing, assigning OutputEncoding only
after, so Console.Out would rebuild on the old code page and redirected
step/substep, whose only sink it is, would stay locale-encoded.
"""
source = path.read_text(encoding = "utf-8")
assert "[Console]::OpenStandardOutput()" in source
assert "[Console]::SetOut(" in source
# stderr is decoded identically and the failure text is built from it.
assert "[Console]::OpenStandardError()" in source
assert "[Console]::SetError(" in source
def test_managed_cli_command_uses_the_utf8_switch_not_just_env() -> None:
"""The managed CLI children must force UTF-8 on the command line.
Every Windows spawn of the CLI now goes through build_managed_cli_command in
process.rs, so that is where the switch has to be; update.rs only calls it.
The env vars alone are not enough: a caller that already exports
PYTHONIOENCODING wins over the ones set beside the spawn, and the Rust
readers decode as UTF-8 regardless. -X utf8 is not overridable that way.
https://docs.python.org/3/using/cmdline.html#cmdoption-X
"""
source = (REPO_ROOT / "studio" / "src-tauri" / "src" / "process.rs").read_text(encoding = "utf-8")
assert re.search(
r'"-X"\s*,\s*"utf8"', source
), "the managed CLI child needs -X utf8, not just the env vars"
@pytest.mark.parametrize(
"rust_file",
["install.rs", "update.rs", "process.rs"],
)
def test_rust_windows_spawns_force_utf8(rust_file: str) -> None:
"""The Rust readers decode as UTF-8, so their Windows children must emit it."""
source = (REPO_ROOT / "studio" / "src-tauri" / "src" / rust_file).read_text(encoding = "utf-8")
assert 'cmd.env("PYTHONUTF8", "1");' in source, f"{rust_file} does not force PYTHONUTF8"
assert (
'cmd.env("PYTHONIOENCODING", "utf-8");' in source
), f"{rust_file} does not force PYTHONIOENCODING"
# The console-less spawn. Windows only, and last in the file because it reuses
# the literal masking above to brace-match the blocks it lifts.
#
# A GitHub runner hands a CREATE_NO_WINDOW child a console anyway
# (GetConsoleOutputCP 437, GetConsoleWindow 0), so the UTF-8 setter in the
# preamble succeeds there and every version of these scripts emits a clean
# banner. The cases above therefore cannot tell this fix from what preceded it.
# Calling FreeConsole() in the child first puts it in the state install.rs's own
# comment assumes CREATE_NO_WINDOW produces, and there the two diverge hard:
# without the sink, Write-Host throws `HostException: GetConsoleScreenBufferInfo,
# The Win32 internal error "The handle is invalid" 0x6`, the script dies with
# exit 1 and 2 bytes of stdout, and the user's setup log holds a PowerShell
# stack trace where the banner should be.
#
# Everything the probe prints is sliced out of the script under test; only the
# FreeConsole prologue and the stderr diagnostics are harness. Restating the
# banner here would only assert that this file can print a sloth.
CREATE_NO_WINDOW = 0x08000000
# studio/src-tauri/src/install.rs::powershell_launch_args, minus the -File the
# runner appends. Not Bypass: RemoteSigned is what the shipped spawn uses.
TAURI_FLAGS = ["-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "RemoteSigned"]
# install.rs::powershell_exe. Resolved absolutely for the same reason it is
# there, and not fallen back to a bare `powershell.exe`: pwsh 7 is UTF-8 by
# default and would pass this without exercising anything.
_WINDOWS_POWERSHELL = (
Path(os.environ.get("SystemRoot", r"C:\Windows"))
/ r"System32\WindowsPowerShell\v1.0\powershell.exe"
if sys.platform == "win32"
else None
)
windows_only = pytest.mark.skipif(
sys.platform != "win32", reason = "the console-less spawn is a Win32 state"
)
powershell_51_only = pytest.mark.skipif(
_WINDOWS_POWERSHELL is None or not _WINDOWS_POWERSHELL.is_file(),
reason = "Windows PowerShell 5.1 is unavailable",
)
# Documented kernel32 calls and nothing else, so the probe reaches the target
# state without the scripts under test knowing they are being tested.
_FREE_CONSOLE = """Add-Type -Namespace Force -Name Native -MemberDefinition @'
[DllImport("kernel32.dll")] public static extern bool FreeConsole();
'@
$null = [Force.Native]::FreeConsole()"""
# The first line of the preamble, and the anchor the slice starts from.
_UTF8_ENCODER = "$_UnslothUtf8NoBom = New-Object System.Text.UTF8Encoding $false"
def _dedent(block: str) -> str:
"""Strip the common indent; install.ps1 defines all of this inside a block."""
indents = [len(line) - len(line.lstrip()) for line in block.split("\n") if line.strip()]
cut = min(indents) if indents else 0
return "\n".join(line[cut:] if line.strip() else "" for line in block.split("\n"))
def _slice_preamble(source: str) -> str:
"""The UTF-8 output invariant, from the encoder to the PYTHONIOENCODING line."""
head = source.rindex("\n", 0, source.index(_UTF8_ENCODER)) + 1
tail = source.index("\n", source.index("$env:PYTHONIOENCODING = 'utf-8'")) + 1
return _dedent(source[head:tail])
def _slice_optional(source: str, pattern: str) -> str | None:
"""None is an answer: main's install.ps1 has neither probe nor sink helper."""
match = re.search(pattern, source)
return _dedent(match.group(0)) if match else None
def _slice_if_chain(source: str, masked: str, start: int) -> str:
"""A whole `if {} else {}`; brace-matching alone drops the non-ANSI branch.
The redirected run is exactly the one that takes that branch.
"""
end = _close_brace(masked, masked.index("{", start))
chained = r"[ \t\r\n]*(?:elseif[ \t]*\(.*?\)|else)[ \t\r\n]*\{"
while True:
tail = re.match(chained, masked[end + 1 :], re.S)
if not tail:
return source[start : end + 1]
end = _close_brace(masked, end + tail.end())
def _slice_banner(source: str, masked: str) -> str:
"""The blank line, the VT/plain branch and the trailing blank, verbatim."""
match = re.search(
r'(?m)^[ \t]*(?:Write-Host|Write-StudioLine) ""\n'
r"[ \t]*if \(\$script:StudioVtOk -and -not \$env:NO_COLOR\) \{",
source,
)
assert match, "no banner block"
block = _slice_if_chain(source, masked, match.end() - 1)
end = match.end() - 1 + len(block)
trailing = re.match(r'\n[ \t]*(?:Write-Host|Write-StudioLine) ""(?=\n)', source[end:])
return _dedent(source[match.start() : end + (trailing.end() if trailing else 0)])
def _console_less_probe(path: Path) -> str:
"""Assemble a probe out of the script's own preamble, helpers and banner."""
source = path.read_text(encoding = "utf-8")
masked = _mask_literals(source)
# Sliced too: it is what turns the Write-Host throw into a dead script
# rather than a skipped line, so restating it would be assuming the result.
eap = _slice_optional(source, r'(?m)^[ \t]*\$ErrorActionPreference = "Stop"')
assert eap, f"{path.name} no longer stops on error before the banner"
parts = [eap, _FREE_CONSOLE, "", _slice_preamble(source), ""]
redirect_probe = _slice_optional(
source,
r"(?m)^[ \t]*\$script:StudioStdoutRedirected = \$false\n"
r"[ \t]*try \{ \$script:StudioStdoutRedirected = \[Console\]::IsOutputRedirected \} catch \{ \}",
)
parts += [redirect_probe or "$script:StudioStdoutRedirected = $false", ""]
for name in ("Write-StudioLine", "Enable-StudioVirtualTerminal", "Get-StudioAnsi"):
if _function_match(masked, name):
lo, hi = _function_span(masked, name)
parts += [_dedent(source[lo:hi]), ""]
parts += ["$script:StudioVtOk = Enable-StudioVirtualTerminal", ""]
for pattern in (
r"(?m)^[ \t]*\$Rule = \[string\]::new\(\[char\]0x2500, 52\)",
r"(?m)^[ \t]*\$Sloth = \[char\]::ConvertFromUtf32\(0x1F9A5\)",
):
# setup.ps1 inlines the sloth in the banner; install.ps1 binds it first.
assignment = _slice_optional(source, pattern)
if assignment:
parts.append(assignment)
parts += ["", _slice_banner(source, masked), ""]
# On stderr, which the app reads on a separate reader, so stdout stays
# exactly the byte stream the log panel is built from.
parts += [
'[Console]::Error.WriteLine("psversion=" + $PSVersionTable.PSVersion.ToString())',
'[Console]::Error.WriteLine("console_outputencoding_codepage=" + [Console]::OutputEncoding.CodePage)',
'[Console]::Error.WriteLine("output_redirected=" + [Console]::IsOutputRedirected)',
'[Console]::Error.WriteLine("studio_stdout_redirected=" + $script:StudioStdoutRedirected)',
'[Console]::Error.WriteLine("studio_vt_ok=" + $script:StudioVtOk)',
]
assembled = "\n".join(parts) + "\n"
# 5.1 parses a BOM-less file as ANSI, which is why both scripts are ASCII-only.
assembled.encode("ascii")
return assembled
@lru_cache(maxsize = None)
def _run_console_less(path: Path, source: str | None = None) -> tuple[int, bytes, str]:
"""Spawn the probe the way install.rs spawns the installer, and read bytes.
`source` is for the VT parity case, which runs this file's own function beside the one it
replaced. A str keeps the lru_cache above workable; a dict would not hash.
"""
with tempfile.TemporaryDirectory() as workdir:
# A file written here has no Zone.Identifier, so RemoteSigned admits it.
probe = Path(workdir) / f"{path.stem}_console_less_probe.ps1"
text = _console_less_probe(path) if source is None else source
probe.write_bytes(text.replace("\n", "\r\n").encode("ascii"))
# run_pwsh, not subprocess.run: the console-less cases are phrased as "this run
# exited non-zero having printed almost nothing", which is also what an aborted
# interpreter looks like, so the two must not be confused. The retry covers 5.1
# here as well. See tests/_shared/unsloth_pwsh_runner.py.
proc = run_pwsh(
[str(_WINDOWS_POWERSHELL), *TAURI_FLAGS, "-File", str(probe)],
stdout = subprocess.PIPE,
stderr = subprocess.PIPE,
creationflags = CREATE_NO_WINDOW,
timeout = 180,
)
return proc.returncode, proc.stdout, proc.stderr.decode("utf-8", errors = "replace")
def _decode_like_install_rs(raw: bytes) -> str:
"""install.rs: read_until(b'\\n') -> trim_line_endings -> from_utf8_lossy.
One record per `install-progress` event, so this is what the log panel
renders. Python's 'replace' emits one U+FFFD per maximal subpart, the rule
Rust's from_utf8_lossy uses.
"""
records = raw.split(b"\n")
if records and records[-1] == b"":
records.pop() # read_until returning Ok(0) at EOF, not an empty line
return "\n".join(r.rstrip(b"\r\n").decode("utf-8", errors = "replace") for r in records)
def _explain(path: Path, code: int, raw: bytes, err: str) -> str:
tail = "\n".join(line for line in err.splitlines() if line.strip())[-1200:]
return (
f"\n{path.name} under a console-less CREATE_NO_WINDOW spawn: exit {code}, "
f"{len(raw)} stdout bytes.\nstderr:\n{tail}\n"
)
# A floor, not the 191 and 207 bytes these banners currently produce. The point
# is only to outrun a dead script: without the sink the run aborts having
# written 2 bytes, and every "nothing is wrong with this stream" assertion below
# holds trivially over 2 bytes. Two of the three cases here are phrased that way
# because that is the regression they guard, so they need this floor underneath
# them or they pass on the very code they exist to reject. Pinning the exact
# count instead would make editing the banner wording a test failure.
_MIN_BANNER_BYTES = 64
def _banner_or_explain(path: Path) -> tuple[str, str]:
"""Run the probe, insist the banner actually arrived, and decode it.
Every console-less case starts here. `raw` being truthy is not enough: the
aborted run is truthy too.
"""
code, raw, err = _run_console_less(path)
detail = _explain(path, code, raw, err)
assert code == 0, (
"the banner block aborted the script instead of printing. Write-Host needs a "
"console screen buffer, and CREATE_NO_WINDOW is documented not to give the "
"child one, so it throws and -ErrorActionPreference Stop takes the run down. "
"The desktop setup log gets a PowerShell stack trace and no banner at all. "
"Route the line through Write-StudioLine." + detail
)
assert len(raw) >= _MIN_BANNER_BYTES, (
f"only {len(raw)} stdout bytes, under the {_MIN_BANNER_BYTES}-byte floor: the "
"banner was LOST, not mangled. Exiting 0 having printed nothing is the same "
"empty setup log to the user as throwing." + detail
)
return _decode_like_install_rs(raw), detail
@windows_only
@powershell_51_only
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_banner_survives_a_console_less_spawn(path: Path) -> None:
"""Without the sink this exits 1 with 2 bytes: the banner never arrives."""
_banner_or_explain(path)
@windows_only
@powershell_51_only
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_console_less_banner_is_valid_utf8(path: Path) -> None:
"""Lossy first: it names how bad the stream is before the strict decode."""
lossy, detail = _banner_or_explain(path)
assert REPLACEMENT not in lossy, (
"the desktop app decodes this pipe as UTF-8 and got bytes that are not, so the "
"log shows U+FFFD. With no console the UTF-8 setter throws, and only the "
"writers bound in its catch branch keep the stream UTF-8." + detail
)
# Strict on purpose; UnicodeDecodeError is the failure. Reruns the cached
# bytes rather than trusting the lossy pass above to have caught everything.
_run_console_less(path)[1].decode("utf-8")
@windows_only
@powershell_51_only
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_console_less_banner_keeps_its_glyphs(path: Path) -> None:
text, detail = _banner_or_explain(path)
assert SLOTH in text, "the sloth did not reach stdout" + detail
assert "??" not in text, "the sloth was transcoded to '?' by a non-UTF-8 code page" + detail
assert text.count(RULE_CHAR * 52) == 1, (
f"expected one 52-char U+2500 rule, found {text.count(RULE_CHAR * 52)}" + detail
)
# Sliced back out to rebuild the function this replaced, so parity is measured against the real
# predecessor. The comments go with the guard: they do not execute, but leaving them behind
# would make the reconstruction something this test invented rather than the merge-base function.
_VT_FAST_PATH = re.compile(
r"(?m)^[ \t]*# A redirected stdout is not a console.*?\n"
r"(?:^[ \t]*#.*\n)*"
r"^[ \t]*if \(\$script:StudioStdoutRedirected\) \{ return \$false \}\n"
)
def _probe_without_the_vt_fast_path(path: Path) -> str:
probe = _console_less_probe(path)
stripped, count = _VT_FAST_PATH.subn("", probe, count = 1)
assert count == 1, (
f"{path.name}: the VT fast path is not in the sliced probe in the shape this test "
f"removes, so nothing was being compared. Update _VT_FAST_PATH."
)
return stripped
def _vt_verdict(err: str) -> str:
for line in err.splitlines():
if line.startswith("studio_vt_ok="):
return line.split("=", 1)[1].strip()
raise AssertionError(f"the probe printed no studio_vt_ok line:\n{err}")
@windows_only
@powershell_51_only
@pytest.mark.parametrize("path", [SETUP_PS1, INSTALL_PS1], ids = ["setup.ps1", "install.ps1"])
def test_vt_fast_path_decides_exactly_as_the_compile_did(path: Path) -> None:
"""Skipping csc.exe must not change one byte the user sees.
This probe is the changed branch, not a bystander: install.rs spawns with a pipe, so
`$script:StudioStdoutRedirected` is true here and the early return is what runs. The
reconstructed predecessor reaches Add-Type instead, and has to land on the same verdict.
"""
new_code, new_raw, new_err = _run_console_less(path)
old_code, old_raw, old_err = _run_console_less(
path, source = _probe_without_the_vt_fast_path(path)
)
assert new_code == old_code == 0, (
f"probe exit codes {new_code} (with the fast path) and {old_code} (without)"
f"{_explain(path, new_code, new_raw, new_err)}"
)
assert _vt_verdict(new_err) == _vt_verdict(old_err) == "False", (
f"a redirected stream cannot render VT: the fast path returned "
f"{_vt_verdict(new_err)} where the compile returned {_vt_verdict(old_err)}"
)
assert new_raw == old_raw, (
"the banner bytes moved. Same verdict in, same bytes out is the whole contract of "
"this change" + _explain(path, new_code, new_raw, new_err)
)