验证:补充2K刷新率与单显示器验收
This commit is contained in:
1 parent
a98d9ddeee
commit
b29b59c62f
21 files changed
+1195
-4
No files matched your search
@@ -0,0 +1,75 @@
|
||||
"""Back up and deploy the VDD login refresh script in VM100, without rebooting."""
|
||||
import argparse
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.parse_args()
|
||||
root = Path(__file__).resolve().parent
|
||||
term = runpy.run_path(str(root / "pve_term_exec.py"))
|
||||
vm = runpy.run_path(str(root / "pve_vm.py"))
|
||||
ticket, csrf = term["login"]()
|
||||
state = vm["api"]("/api2/json/nodes/starhe/qemu/100/status/current", ticket=ticket)
|
||||
if state.get("status") != "running":
|
||||
sys.exit("VM100 is not running; nothing changed")
|
||||
payload = base64.b64encode((root / "set-2k-display.ps1").read_bytes()).decode()
|
||||
command = r"""
|
||||
$ErrorActionPreference='Stop'
|
||||
$target='C:\ProgramData\VDI\set-2k-display.ps1'
|
||||
$task='VDISet2KDisplay'
|
||||
$entry=Get-ScheduledTask -TaskName $task
|
||||
if($entry.State -eq 'Running') { throw 'Display task is already running; inspect before deployment' }
|
||||
if(-not (Test-Path -LiteralPath $target)) { throw 'Original display script is missing' }
|
||||
if(($entry.Actions | Select-Object -First 1).Arguments -notlike '*set-2k-display.ps1*') {
|
||||
throw 'Unexpected display task action; do not change it'
|
||||
}
|
||||
$backup='C:\ProgramData\VDI\backups\display-refresh-'+(Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||
New-Item -ItemType Directory -Path $backup | Out-Null
|
||||
Copy-Item -LiteralPath $target -Destination "$backup\set-2k-display.ps1"
|
||||
Export-ScheduledTask -TaskName $task | Set-Content -Encoding UTF8 "$backup\VDISet2KDisplay.xml"
|
||||
if(Test-Path 'C:\ProgramData\VDI\set-2k-result.txt') {
|
||||
Copy-Item 'C:\ProgramData\VDI\set-2k-result.txt' "$backup\previous-result.txt"
|
||||
}
|
||||
$temporary=$target+'.new'
|
||||
try {
|
||||
[IO.File]::WriteAllBytes($temporary,[Convert]::FromBase64String('__PAYLOAD__'))
|
||||
Move-Item -LiteralPath $temporary -Destination $target -Force
|
||||
# Prove freshness by a newly created result file; scheduler LastRunTime can
|
||||
# lag wall-clock time after VM clock correction and is not a reliable token.
|
||||
Remove-Item -LiteralPath 'C:\ProgramData\VDI\set-2k-result.txt' -ErrorAction SilentlyContinue
|
||||
Start-ScheduledTask -TaskName $task
|
||||
Start-Sleep -Seconds 2
|
||||
for($i=0;$i -lt 20;$i++) {
|
||||
if((Get-ScheduledTask -TaskName $task).State -ne 'Running') { break }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
$info=Get-ScheduledTaskInfo -TaskName $task
|
||||
if((Get-ScheduledTask -TaskName $task).State -eq 'Running' -or
|
||||
$info.LastTaskResult -ne 0) {
|
||||
throw 'New display task did not complete successfully'
|
||||
}
|
||||
$log=Get-Content 'C:\ProgramData\VDI\set-2k-result.txt' -Raw
|
||||
if($log -notmatch 'result=0 mode=2560x1440@60') { throw '60Hz read-back missing in display task log' }
|
||||
@{backup=$backup; task_result=$info.LastTaskResult; result=$log.Trim()} | ConvertTo-Json -Compress
|
||||
} catch {
|
||||
if(Test-Path 'C:\ProgramData\VDI\set-2k-result.txt') {
|
||||
Copy-Item 'C:\ProgramData\VDI\set-2k-result.txt' "$backup\failed-result.txt"
|
||||
Get-Content "$backup\failed-result.txt"
|
||||
}
|
||||
if($info) { $info | Select-Object LastTaskResult,LastRunTime | ConvertTo-Json -Compress }
|
||||
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
|
||||
Copy-Item -LiteralPath "$backup\set-2k-display.ps1" -Destination $target -Force
|
||||
Start-ScheduledTask -TaskName $task
|
||||
throw
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $temporary -ErrorAction SilentlyContinue
|
||||
}
|
||||
""".replace("__PAYLOAD__", payload)
|
||||
out, error = vm["agent"](ticket, csrf, ["powershell.exe", "-NoProfile", "-Command", command])
|
||||
print(out)
|
||||
if error or '"task_result":0' not in out:
|
||||
print(error, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Query or detach extra Windows desktop outputs in VM100's interactive session."""
|
||||
import argparse
|
||||
import base64
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("action", choices=["Query","Apply","Restore"])
|
||||
args=parser.parse_args()
|
||||
root=Path(__file__).resolve().parent
|
||||
t=runpy.run_path(str(root/"pve_term_exec.py")); v=runpy.run_path(str(root/"pve_vm.py"))
|
||||
ticket,csrf=t["login"]()
|
||||
if v["api"]("/api2/json/nodes/starhe/qemu/100/status/current",ticket=ticket).get("status")!="running":
|
||||
sys.exit("VM100 is not running; nothing changed")
|
||||
payload=base64.b64encode((root/"set-single-vdi-display.ps1").read_bytes()).decode()
|
||||
command=r"""
|
||||
$ErrorActionPreference='Stop'
|
||||
$folder='C:\ProgramData\VDI\single-display-20261103'
|
||||
$task='VDISingleDisplayProbe'
|
||||
if(Get-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue) { throw 'Task already exists' }
|
||||
$user=(Get-CimInstance Win32_ComputerSystem).UserName
|
||||
if(-not $user) { throw 'No interactive user' }
|
||||
New-Item -ItemType Directory -Force $folder | Out-Null
|
||||
$acl=Get-Acl $folder
|
||||
$acl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($user,'Modify','ContainerInherit,ObjectInherit','None','Allow')))
|
||||
Set-Acl $folder $acl
|
||||
[IO.File]::WriteAllBytes("$folder\single.ps1",[Convert]::FromBase64String('__PAYLOAD__'))
|
||||
Remove-Item "$folder\result.txt" -ErrorAction SilentlyContinue
|
||||
$run="try { & '$folder\single.ps1' -Action __ACTION__ | Out-File -Encoding utf8 -Width 500 '$folder\result.txt' } catch { `$_ | Out-String | Out-File -Encoding utf8 -Width 500 '$folder\result.txt'; exit 1 }"
|
||||
$encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($run))
|
||||
$a=New-ScheduledTaskAction -Execute powershell.exe -Argument "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -EncodedCommand $encoded"
|
||||
$p=New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
|
||||
$s=New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 2)
|
||||
Register-ScheduledTask -TaskName $task -Action $a -Principal $p -Settings $s | Out-Null
|
||||
try {
|
||||
Start-ScheduledTask -TaskName $task
|
||||
Start-Sleep -Seconds 2
|
||||
for($i=0;$i-lt 30;$i++) {
|
||||
if((Get-ScheduledTask -TaskName $task).State-ne 'Running') { break }
|
||||
Start-Sleep -Seconds 1
|
||||
}
|
||||
if((Get-ScheduledTask -TaskName $task).State-eq 'Running') { throw 'Probe timed out' }
|
||||
Get-Content "$folder\result.txt" -Raw
|
||||
'task_result='+(Get-ScheduledTaskInfo -TaskName $task).LastTaskResult
|
||||
} finally {
|
||||
Stop-ScheduledTask -TaskName $task -ErrorAction SilentlyContinue
|
||||
Unregister-ScheduledTask -TaskName $task -Confirm:$false
|
||||
}
|
||||
""".replace("__PAYLOAD__",payload).replace("__ACTION__",args.action)
|
||||
out,err=v["agent"](ticket,csrf,["powershell.exe","-NoProfile","-Command",command])
|
||||
print(out)
|
||||
if err or "task_result=0" not in out:
|
||||
sys.exit(err or "Display operation did not succeed")
|
||||
@@ -1,3 +1,4 @@
|
||||
param([ValidateSet(30, 60)][int]$RefreshRate = 60)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type @'
|
||||
using System;
|
||||
@@ -45,12 +46,12 @@ public static class VdiDisplayMode {
|
||||
if (!EnumDisplaySettings(name, -1, ref m)) return "unavailable";
|
||||
return m.width + "x" + m.height + "@" + m.hz;
|
||||
}
|
||||
public static int Apply(string name) {
|
||||
public static int Apply(string name, int refreshRate) {
|
||||
for (int i = 0; ; i++) {
|
||||
var m = new Mode();
|
||||
m.size = (short)Marshal.SizeOf(typeof(Mode));
|
||||
if (!EnumDisplaySettings(name, i, ref m)) return -99;
|
||||
if (m.width == 2560 && m.height == 1440 && m.hz == 30) {
|
||||
if (m.width == 2560 && m.height == 1440 && m.hz == refreshRate) {
|
||||
m.fields = 0x80000 | 0x100000 | 0x400000;
|
||||
int test = ChangeDisplaySettingsEx(name, ref m, IntPtr.Zero, 2, IntPtr.Zero);
|
||||
if (test != 0) return test;
|
||||
@@ -61,13 +62,18 @@ public static class VdiDisplayMode {
|
||||
}
|
||||
'@
|
||||
$log = 'C:/ProgramData/VDI/set-2k-result.txt'
|
||||
$expectedMode = "2560x1440@$RefreshRate"
|
||||
try {
|
||||
for ($i = 0; $i -lt 12; $i++) {
|
||||
$dev = [VdiDisplayMode]::Find()
|
||||
if ($dev) {
|
||||
$result = [VdiDisplayMode]::Apply($dev)
|
||||
if ([VdiDisplayMode]::Current($dev) -eq $expectedMode) {
|
||||
"$(Get-Date -Format o) device=$dev result=0 mode=$expectedMode (already correct)" | Set-Content $log
|
||||
exit 0
|
||||
}
|
||||
$result = [VdiDisplayMode]::Apply($dev, $RefreshRate)
|
||||
$mode = [VdiDisplayMode]::Current($dev)
|
||||
if ($result -eq 0 -and $mode -eq '2560x1440@30') {
|
||||
if ($result -eq 0 -and $mode -eq $expectedMode) {
|
||||
"$(Get-Date -Format o) device=$dev result=0 mode=$mode" | Set-Content $log
|
||||
exit 0
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
param(
|
||||
[ValidateSet('Query','Apply','Restore')][string]$Action='Query',
|
||||
[string]$BackupPath='C:\ProgramData\VDI\single-display-20261103\before.xml'
|
||||
)
|
||||
$ErrorActionPreference='Stop'
|
||||
# Run as the logged-in desktop user. Detach desktop outputs, never disable GPUs.
|
||||
Add-Type -ReferencedAssemblies System.Xml,System.Core,System -TypeDefinition @'
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Xml.Serialization;
|
||||
public static class VdiSingleDisplay {
|
||||
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]
|
||||
public struct Device {
|
||||
public int cb;
|
||||
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string Name;
|
||||
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Description;
|
||||
public int Flags;
|
||||
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Id;
|
||||
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=128)] public string Key;
|
||||
}
|
||||
[StructLayout(LayoutKind.Sequential,CharSet=CharSet.Unicode)]
|
||||
public struct Mode {
|
||||
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string name;
|
||||
public short spec,driver,size,extra;
|
||||
public int fields,x,y,orientation,fixedOutput;
|
||||
public short color,duplex,yres,tt,collate;
|
||||
[MarshalAs(UnmanagedType.ByValTStr,SizeConst=32)] public string form;
|
||||
public short logPixels;
|
||||
public int bpp,width,height,flags,hz,icmMethod,icmIntent,media,dither,reserved1,reserved2,panWidth,panHeight;
|
||||
}
|
||||
public class Record { public Device device; public Mode mode; public bool hasMode; }
|
||||
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern bool EnumDisplayDevices(string name,uint index,ref Device device,uint flags);
|
||||
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern bool EnumDisplaySettings(string name,int index,ref Mode mode);
|
||||
[DllImport("user32.dll",CharSet=CharSet.Unicode)] static extern int ChangeDisplaySettingsEx(string name,ref Mode mode,IntPtr hwnd,uint flags,IntPtr param);
|
||||
[DllImport("user32.dll",CharSet=CharSet.Unicode,EntryPoint="ChangeDisplaySettingsExW")]
|
||||
static extern int Commit(string name,IntPtr mode,IntPtr hwnd,uint flags,IntPtr param);
|
||||
public static Record[] Query() {
|
||||
var result=new List<Record>();
|
||||
for(uint i=0;;i++) {
|
||||
Device d=new Device(); d.cb=Marshal.SizeOf(typeof(Device));
|
||||
if(!EnumDisplayDevices(null,i,ref d,0)) return result.ToArray();
|
||||
if((d.Flags&8)!=0) continue;
|
||||
Mode m=new Mode(); m.size=(short)Marshal.SizeOf(typeof(Mode));
|
||||
bool ok=EnumDisplaySettings(d.Name,-1,ref m);
|
||||
result.Add(new Record {device=d,mode=m,hasMode=ok});
|
||||
}
|
||||
}
|
||||
static void Check(int code,string operation) {
|
||||
if(code!=0) throw new InvalidOperationException(operation+" failed: "+code+" (no reboot will be requested)");
|
||||
}
|
||||
public static string Target(Record[] records) {
|
||||
string found=null;
|
||||
foreach(var r in records) if((r.device.Flags&1)!=0 && r.device.Description.IndexOf("Virtual Display Driver",StringComparison.OrdinalIgnoreCase)>=0) {
|
||||
if(found!=null) throw new InvalidOperationException("Multiple active VDD outputs; refuse ambiguous selection");
|
||||
if(!r.hasMode || r.mode.width<640 || r.mode.height<480) throw new InvalidOperationException("Invalid active VDD mode");
|
||||
found=r.device.Name;
|
||||
}
|
||||
if(found==null) throw new InvalidOperationException("No active VDD; do not detach anything");
|
||||
return found;
|
||||
}
|
||||
public static void Save(Record[] records,string path) {
|
||||
using(var stream=new FileStream(path,FileMode.CreateNew,FileAccess.Write)) new XmlSerializer(typeof(Record[])).Serialize(stream,records);
|
||||
}
|
||||
public static Record[] Load(string path) {
|
||||
using(var stream=File.OpenRead(path)) return (Record[])new XmlSerializer(typeof(Record[])).Deserialize(stream);
|
||||
}
|
||||
static void Stage(Record record,bool active,bool primary) {
|
||||
Mode m=record.mode; m.size=(short)Marshal.SizeOf(typeof(Mode)); m.extra=0;
|
||||
if(active) {
|
||||
if(!record.hasMode) throw new InvalidOperationException("Missing original mode");
|
||||
m.fields=0x20|0x40000|0x80000|0x100000|0x400000;
|
||||
} else {
|
||||
m.width=m.height=0; m.fields=0x20|0x80000|0x100000;
|
||||
}
|
||||
Check(ChangeDisplaySettingsEx(record.device.Name,ref m,IntPtr.Zero,0x10000001u|(primary?0x10u:0u),IntPtr.Zero),"Stage "+record.device.Name);
|
||||
}
|
||||
public static void Apply(Record[] before,string keep) {
|
||||
foreach(var r0 in before) if(r0.device.Name==keep) {
|
||||
Record r=new Record {device=r0.device,mode=r0.mode,hasMode=r0.hasMode};
|
||||
Mode m=r.mode; m.x=m.y=0; r.mode=m; Stage(r,true,true);
|
||||
}
|
||||
foreach(var r in before) if(r.device.Name!=keep && (r.device.Flags&1)!=0) Stage(r,false,false);
|
||||
Check(Commit(null,IntPtr.Zero,IntPtr.Zero,0,IntPtr.Zero),"Commit single display");
|
||||
}
|
||||
public static void Restore(Record[] before) {
|
||||
Record[] now=Query();
|
||||
foreach(var old in before) {
|
||||
bool matched=false;
|
||||
foreach(var r in now) if(r.device.Name==old.device.Name && r.device.Id==old.device.Id && r.device.Key==old.device.Key) matched=true;
|
||||
if(!matched) throw new InvalidOperationException("Display identity changed; refuse stale restore: "+old.device.Name);
|
||||
}
|
||||
foreach(var r in before) if((r.device.Flags&4)!=0) Stage(r,true,true);
|
||||
foreach(var r in before) if((r.device.Flags&4)==0) {
|
||||
bool currentlyActive=false;
|
||||
foreach(var current in now) if(current.device.Name==r.device.Name) currentlyActive=(current.device.Flags&1)!=0;
|
||||
if((r.device.Flags&1)!=0 || currentlyActive) Stage(r,(r.device.Flags&1)!=0,false);
|
||||
}
|
||||
Check(Commit(null,IntPtr.Zero,IntPtr.Zero,0,IntPtr.Zero),"Restore topology");
|
||||
}
|
||||
}
|
||||
'@
|
||||
function Report {
|
||||
@([VdiSingleDisplay]::Query() | ForEach-Object {
|
||||
@{device=$_.device.Name; description=$_.device.Description; active=(($_.device.Flags-band 1)-ne 0);
|
||||
primary=(($_.device.Flags-band 4)-ne 0); width=$_.mode.width; height=$_.mode.height; hz=$_.mode.hz; x=$_.mode.x; y=$_.mode.y}
|
||||
}) | ConvertTo-Json -Depth 4 -Compress
|
||||
}
|
||||
if($Action -eq 'Query') { Report; exit 0 }
|
||||
if($Action -eq 'Restore') { [VdiSingleDisplay]::Restore([VdiSingleDisplay]::Load($BackupPath)); Report; exit 0 }
|
||||
$before=[VdiSingleDisplay]::Query()
|
||||
$keep=[VdiSingleDisplay]::Target($before)
|
||||
$target=@($before | Where-Object {$_.device.Name -eq $keep})[0]
|
||||
if($target.mode.width -ne 2560 -or $target.mode.height -ne 1440 -or $target.mode.hz -ne 60 -or ($target.device.Flags-band 4)-eq 0) {
|
||||
throw 'Required existing 2560x1440@60 VDD primary is absent; nothing changed'
|
||||
}
|
||||
$active=@($before | Where-Object {($_.device.Flags-band 1)-ne 0})
|
||||
if($active.Count -eq 1 -and ($active[0].device.Flags-band 4)-ne 0) { Report; exit 0 }
|
||||
New-Item -ItemType Directory -Force (Split-Path $BackupPath) | Out-Null
|
||||
[VdiSingleDisplay]::Save($before,$BackupPath)
|
||||
try {
|
||||
[VdiSingleDisplay]::Apply($before,$keep)
|
||||
Start-Sleep -Seconds 2
|
||||
$actual=@([VdiSingleDisplay]::Query() | Where-Object {($_.device.Flags-band 1)-ne 0})
|
||||
if($actual.Count -ne 1 -or $actual[0].device.Name -ne $keep -or ($actual[0].device.Flags-band 4)-eq 0) { throw 'Single VDD primary read-back failed' }
|
||||
$old=@($before | Where-Object {$_.device.Name -eq $keep})[0]
|
||||
if($actual[0].mode.width-ne $old.mode.width -or $actual[0].mode.height-ne $old.mode.height -or $actual[0].mode.hz-ne $old.mode.hz) { throw 'VDD mode changed unexpectedly' }
|
||||
Report
|
||||
} catch {
|
||||
$failure=$_
|
||||
[VdiSingleDisplay]::Restore($before)
|
||||
throw $failure
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Query or change only the Windows desktop FPS in the box's local POC config."""
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--fps", type=int, choices=[30, 45, 60], required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
program = r'''
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import json, os, shutil, stat, tempfile
|
||||
path=Path.home()/'.config/vdi-terminal/desktops.json'
|
||||
original=path.read_bytes()
|
||||
data=json.loads(original)
|
||||
matches=[d for d in data['desktops'] if d.get('id')=='desk-win10-01']
|
||||
if len(matches)!=1 or matches[0].get('endpoint')!='192.168.1.70':
|
||||
raise RuntimeError('Unexpected Windows desktop identity; inspect before changing')
|
||||
desktop=matches[0]
|
||||
before=desktop.get('fps')
|
||||
backup=None
|
||||
if __APPLY__:
|
||||
backup=Path.home()/'vdi-terminal/backups'/('fps-'+datetime.now().strftime('%Y%m%d-%H%M%S-%f'))
|
||||
backup.mkdir(parents=True)
|
||||
shutil.copy2(path,backup/'desktops.json')
|
||||
desktop['fps']=__FPS__
|
||||
name=None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent,prefix='.desktops-',delete=False) as stream:
|
||||
name=stream.name
|
||||
stream.write((json.dumps(data,ensure_ascii=False,indent=2)+'\n').encode())
|
||||
os.chmod(name,stat.S_IMODE(path.stat().st_mode))
|
||||
if path.read_bytes()!=original:
|
||||
raise RuntimeError('Desktop configuration changed concurrently; left it intact')
|
||||
os.replace(name,path)
|
||||
name=None
|
||||
if json.loads(path.read_bytes())!=data:
|
||||
raise RuntimeError('Desktop configuration read-back mismatch')
|
||||
finally:
|
||||
if name is not None: os.unlink(name)
|
||||
print(json.dumps({'desktop':desktop['id'],'before':before,'fps':desktop['fps'],
|
||||
'requested':__FPS__,'applied':__APPLY__,
|
||||
'backup':str(backup) if backup else None}))
|
||||
'''.replace("__FPS__", str(args.fps)).replace("__APPLY__", str(args.apply))
|
||||
result = subprocess.run(["ssh", "-o", "ConnectTimeout=8", "armbian@192.168.1.188", "python3 -"],
|
||||
input=program, capture_output=True, text=True, encoding="utf-8")
|
||||
print(result.stdout, end="")
|
||||
if result.stderr:
|
||||
print(result.stderr, end="", file=sys.stderr)
|
||||
sys.exit(result.returncode)
|
||||
Reference in new issue
Block a user