mirror of
https://github.com/tangerineturtle1/NX2navi.git
synced 2026-11-15 19:19:58 -04:00
Keep reading the build's output when a handler fails.
build.Run read the build's output on a thread that ended on the first exception of a line or event handler, such as the launcher's log on a full disk. Nobody read the pipe after that, the build stopped at its next line once the pipe was full, and the job waited for it for good. The thread now counts the line as dropped and reads on, and Context.log loses a line it cannot write instead of raising. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
@@ -73,26 +73,40 @@ class Run:
|
||||
self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
|
||||
cwd=cwd, env=env, text=True, encoding='utf-8', errors='replace', bufsize=1, **kw)
|
||||
self.cancelled = False
|
||||
self.dropped = 0 # lines and events whose handler raised (_read)
|
||||
self._reader = threading.Thread(target=self._read, args=(on_event, on_line), daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
def _read(self, on_event, on_line):
|
||||
"""Reads the build's output to its end, whatever the handlers do. A handler that raises (the launcher's log
|
||||
on a full disk, a drawing error) loses its line, counted in dropped, and the reading goes on: when this
|
||||
thread ended on the exception nobody read the pipe any more, the build stopped at its next line once the
|
||||
pipe was full, and the job waited for it for good."""
|
||||
def call(handler, arg):
|
||||
try:
|
||||
handler(arg)
|
||||
except Exception: # noqa: BLE001 - any failure of a handler must leave the pipe read
|
||||
self.dropped += 1
|
||||
|
||||
for line in self.proc.stdout:
|
||||
line = line.rstrip('\r\n')
|
||||
if line.startswith('@@'):
|
||||
try:
|
||||
ev = json.loads(line[2:])
|
||||
except ValueError:
|
||||
on_line(line)
|
||||
call(on_line, line)
|
||||
continue
|
||||
if ev.get('ev') == 'error':
|
||||
self.error = ev.get('message')
|
||||
on_event(ev)
|
||||
if isinstance(ev, dict):
|
||||
if ev.get('ev') == 'error':
|
||||
self.error = ev.get('message')
|
||||
call(on_event, ev)
|
||||
else: # "@@" before something that is no event
|
||||
call(on_line, line)
|
||||
elif line.startswith('error: ') and not self.error:
|
||||
self.error = line[7:]
|
||||
on_line(line)
|
||||
call(on_line, line)
|
||||
else:
|
||||
on_line(line)
|
||||
call(on_line, line)
|
||||
|
||||
def wait(self):
|
||||
code = self.proc.wait()
|
||||
|
||||
@@ -34,12 +34,18 @@ class Context:
|
||||
'it was is kept as launcher.json.bad')
|
||||
|
||||
def log(self, line):
|
||||
"""One line (or several) into launcher.log. A log that cannot be written (a full disk, a file closed under
|
||||
it) loses the line and nothing else: the callers log from the build's reader thread and from the window's
|
||||
timers, where an exception would stop the build's output from being read or the timer from running."""
|
||||
stamp = time.strftime('%H:%M:%S')
|
||||
with self._lock:
|
||||
if self._file:
|
||||
for part in str(line).splitlines() or ['']:
|
||||
self._file.write(f'{stamp} {part}\n')
|
||||
self._file.flush()
|
||||
try:
|
||||
for part in str(line).splitlines() or ['']:
|
||||
self._file.write(f'{stamp} {part}\n')
|
||||
self._file.flush()
|
||||
except (OSError, ValueError): # ValueError: the file was closed
|
||||
pass
|
||||
if self.echo:
|
||||
print(line, flush=True)
|
||||
|
||||
|
||||
@@ -2761,6 +2761,52 @@ class JobTest(Tmp):
|
||||
self.assertEqual(run.wait(), 0, lines)
|
||||
self.assertEqual(lines, ['build 0x4000', 'compiler 0x4000']) # BELOW_NORMAL_PRIORITY_CLASS
|
||||
|
||||
def test_the_build_is_read_to_its_end_when_a_handler_fails(self):
|
||||
"""A line handler that raises (the log on a full disk) must not end the thread that reads the build's
|
||||
output: the build would stop at a full pipe and never exit."""
|
||||
child = [sys.executable, '-c',
|
||||
'import json\n'
|
||||
'for i in range(20000):\n'
|
||||
' print("line", i, "x" * 60)\n'
|
||||
'print("@@[1]")\n'
|
||||
'print("@@" + json.dumps({"ev": "end"}))\n']
|
||||
events = []
|
||||
|
||||
def on_line(line):
|
||||
raise OSError(28, 'No space left on device')
|
||||
|
||||
def on_event(ev):
|
||||
events.append(ev)
|
||||
raise RuntimeError('a drawing error')
|
||||
run = build.Run(child, on_event, on_line)
|
||||
try:
|
||||
code = run.proc.wait(timeout=60)
|
||||
except subprocess.TimeoutExpired:
|
||||
run.cancel()
|
||||
self.fail('the build never ended: nobody read its output any more')
|
||||
run.wait()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertEqual(events, [{'ev': 'end'}])
|
||||
self.assertEqual(run.dropped, 20002) # every line, the "@@" line that is no event, and the event
|
||||
|
||||
def test_a_log_that_cannot_be_written_loses_only_the_line(self):
|
||||
ctx = self.make_ctx('ok')
|
||||
real = ctx._file
|
||||
self.addCleanup(real.close)
|
||||
ctx._file = mock.Mock()
|
||||
ctx._file.write.side_effect = OSError(28, 'No space left on device')
|
||||
ctx.log('a line while the disk is full')
|
||||
j = jobs.Job(ctx, [])
|
||||
j.log('a line of the build') # what the build's reader thread calls
|
||||
self.assertEqual(list(j.lines), ['a line of the build'])
|
||||
ctx._file = real
|
||||
ctx.log('the disk has room again')
|
||||
real.close()
|
||||
ctx.log('a line after the file was closed')
|
||||
text = (ctx.layout.logs / 'launcher.log').read_text(encoding='utf-8')
|
||||
self.assertIn('the disk has room again', text)
|
||||
self.assertNotIn('while the disk is full', text)
|
||||
|
||||
def test_an_install_goes_on_from_the_unpacked_game_files(self):
|
||||
"""Begin Installation into a folder whose game files an earlier run unpacked builds on from them (--rebuild
|
||||
for this processor's level), so a moved or deleted dump is not asked for; without them it reads the dump."""
|
||||
|
||||
Reference in New Issue
Block a user