Skip to content

Commit 39b0306

Browse files
committed
fix: handle console ansi and interrupts
1 parent 72ebedc commit 39b0306

7 files changed

Lines changed: 306 additions & 15 deletions

File tree

app/core/pty_console.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import signal
88
import subprocess
99
from collections.abc import Awaitable, Callable
10+
from contextlib import suppress
1011

1112

1213
async def bridge_pty(
@@ -23,19 +24,23 @@ async def bridge_pty(
2324
start_new_session=True,
2425
)
2526
os.close(slave_fd)
27+
os.set_blocking(master_fd, False)
2628
loop = asyncio.get_running_loop()
2729
reader_task = asyncio.create_task(_read_pty(loop, master_fd, send_text))
28-
writer_task = asyncio.create_task(_write_pty(master_fd, receive_text))
30+
writer_task = asyncio.create_task(_write_pty(master_fd, process.pid, receive_text))
2931
disconnected = False
3032
try:
3133
while process.poll() is None:
3234
if writer_task.done():
35+
signal_exit_code = writer_task.result()
36+
if signal_exit_code is not None:
37+
return signal_exit_code
3338
disconnected = True
3439
break
3540
await asyncio.sleep(0.1)
3641
if disconnected:
3742
return 130
38-
return process.returncode or 0
43+
return _display_exit_code(process.returncode)
3944
finally:
4045
writer_task.cancel()
4146
reader_task.cancel()
@@ -51,22 +56,45 @@ async def _read_pty(
5156
) -> None:
5257
while True:
5358
try:
54-
data = await loop.run_in_executor(None, os.read, master_fd, 4096)
59+
data = os.read(master_fd, 4096)
60+
except BlockingIOError:
61+
await asyncio.sleep(0.02)
62+
continue
5563
except OSError:
5664
return
5765
if not data:
5866
return
5967
await send_text(data.decode(errors="replace"))
6068

6169

62-
async def _write_pty(master_fd: int, receive_text: Callable[[], Awaitable[str]]) -> None:
70+
async def _write_pty(
71+
master_fd: int,
72+
process_pid: int,
73+
receive_text: Callable[[], Awaitable[str]],
74+
) -> int | None:
6375
while True:
6476
try:
6577
text = await receive_text()
78+
if "\x03" in text:
79+
with suppress(ProcessLookupError):
80+
os.killpg(process_pid, signal.SIGINT)
81+
return 130
82+
if "\x04" in text:
83+
with suppress(ProcessLookupError):
84+
os.killpg(process_pid, signal.SIGHUP)
85+
return 129
6686
os.write(master_fd, text.encode())
6787
except RuntimeError, OSError, asyncio.CancelledError:
68-
return
88+
return None
6989

7090

7191
def display_argv(argv: list[str]) -> str:
7292
return " ".join(shlex.quote(part) for part in argv)
93+
94+
95+
def _display_exit_code(returncode: int | None) -> int:
96+
if returncode is None:
97+
return 0
98+
if returncode < 0:
99+
return 128 + abs(returncode)
100+
return returncode

app/static/ansi.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
(() => {
2+
const escapeHtml = (text) => text
3+
.replaceAll("&", "&amp;")
4+
.replaceAll("<", "&lt;")
5+
.replaceAll(">", "&gt;");
6+
7+
const colorClasses = {
8+
30: "ansi-black",
9+
31: "ansi-red",
10+
32: "ansi-green",
11+
33: "ansi-yellow",
12+
34: "ansi-blue",
13+
35: "ansi-magenta",
14+
36: "ansi-cyan",
15+
37: "ansi-white",
16+
90: "ansi-black",
17+
91: "ansi-red",
18+
92: "ansi-green",
19+
93: "ansi-yellow",
20+
94: "ansi-blue",
21+
95: "ansi-magenta",
22+
96: "ansi-cyan",
23+
97: "ansi-white",
24+
};
25+
26+
function activeClasses(state) {
27+
const classes = [];
28+
if (state.bold) {
29+
classes.push("ansi-bold");
30+
}
31+
if (state.dim) {
32+
classes.push("ansi-dim");
33+
}
34+
if (state.italic) {
35+
classes.push("ansi-italic");
36+
}
37+
if (state.underline) {
38+
classes.push("ansi-underline");
39+
}
40+
if (state.color) {
41+
classes.push(state.color);
42+
}
43+
return classes;
44+
}
45+
46+
function openSpan(state) {
47+
const classes = activeClasses(state);
48+
if (classes.length === 0) {
49+
return "";
50+
}
51+
return `<span class="${classes.join(" ")}">`;
52+
}
53+
54+
function hasStyle(state) {
55+
return activeClasses(state).length > 0;
56+
}
57+
58+
function applyCodes(state, codes) {
59+
for (const code of codes) {
60+
if (code === 0) {
61+
state.bold = false;
62+
state.dim = false;
63+
state.italic = false;
64+
state.underline = false;
65+
state.color = "";
66+
} else if (code === 1) {
67+
state.bold = true;
68+
} else if (code === 2) {
69+
state.dim = true;
70+
} else if (code === 3) {
71+
state.italic = true;
72+
} else if (code === 4) {
73+
state.underline = true;
74+
} else if (code === 22) {
75+
state.bold = false;
76+
state.dim = false;
77+
} else if (code === 23) {
78+
state.italic = false;
79+
} else if (code === 24) {
80+
state.underline = false;
81+
} else if (code === 39) {
82+
state.color = "";
83+
} else if (code in colorClasses) {
84+
state.color = colorClasses[code];
85+
}
86+
}
87+
}
88+
89+
function render(text) {
90+
const state = { bold: false, dim: false, italic: false, underline: false, color: "" };
91+
let output = "";
92+
let open = false;
93+
let index = 0;
94+
const sgr = /\x1b\[([0-9;]*)m/g;
95+
for (const match of text.matchAll(sgr)) {
96+
output += escapeHtml(text.slice(index, match.index));
97+
if (open) {
98+
output += "</span>";
99+
open = false;
100+
}
101+
const codes = match[1] === "" ? [0] : match[1].split(";").map(Number);
102+
applyCodes(state, codes);
103+
if (hasStyle(state)) {
104+
output += openSpan(state);
105+
open = true;
106+
}
107+
index = match.index + match[0].length;
108+
}
109+
output += escapeHtml(text.slice(index));
110+
if (open) {
111+
output += "</span>";
112+
}
113+
return output;
114+
}
115+
116+
window.rcloneRunnerAnsi = { render };
117+
})();

app/static/styles.css

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,54 @@ textarea {
742742
color: var(--terminal-bg);
743743
}
744744

745+
.ansi-bold {
746+
font-weight: 700;
747+
}
748+
749+
.ansi-dim {
750+
opacity: 0.72;
751+
}
752+
753+
.ansi-italic {
754+
font-style: italic;
755+
}
756+
757+
.ansi-underline {
758+
text-decoration: underline;
759+
}
760+
761+
.ansi-black {
762+
color: #8d9688;
763+
}
764+
765+
.ansi-red {
766+
color: #ff8a80;
767+
}
768+
769+
.ansi-green {
770+
color: #93d993;
771+
}
772+
773+
.ansi-yellow {
774+
color: #f2d574;
775+
}
776+
777+
.ansi-blue {
778+
color: #8db7ff;
779+
}
780+
781+
.ansi-magenta {
782+
color: #dca0ff;
783+
}
784+
785+
.ansi-cyan {
786+
color: #7bd6d8;
787+
}
788+
789+
.ansi-white {
790+
color: #f5f8f2;
791+
}
792+
745793
.notice {
746794
background: var(--notice-bg);
747795
border: 1px solid var(--notice-border);

app/templates/_log_viewer.html

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
>{{ log_chunk.text }}</pre>
1717
</div>
1818

19+
<script src="/static/ansi.js"></script>
1920
<script>
2021
(() => {
2122
const output = document.querySelector("#log-output");
@@ -28,6 +29,11 @@
2829
let loading = false;
2930
let live = output.dataset.live === "true";
3031
let appendOffset = Number.parseInt(output.dataset.appendOffset || "0", 10);
32+
let rawLogText = output.textContent;
33+
34+
function renderLog() {
35+
output.innerHTML = rcloneRunnerAnsi.render(rawLogText);
36+
}
3137

3238
function hasMore() {
3339
return output.dataset.hasMore === "true" && output.dataset.nextBefore !== "";
@@ -47,7 +53,8 @@
4753
const previousHeight = output.scrollHeight;
4854
const response = await fetch(`${output.dataset.chunkUrl}?before=${output.dataset.nextBefore}`);
4955
const chunk = await response.json();
50-
output.textContent = `${chunk.text}${output.textContent}`;
56+
rawLogText = `${chunk.text}${rawLogText}`;
57+
renderLog();
5158
output.dataset.nextBefore = chunk.next_before ?? "";
5259
output.dataset.hasMore = chunk.has_more ? "true" : "false";
5360
output.scrollTop = output.scrollHeight - previousHeight;
@@ -94,7 +101,8 @@
94101
appendOffset = chunk.offset ?? appendOffset;
95102
output.dataset.appendOffset = appendOffset;
96103
if (chunk.text) {
97-
output.textContent = `${output.textContent}${chunk.text}`;
104+
rawLogText = `${rawLogText}${chunk.text}`;
105+
renderLog();
98106
if (pinned) {
99107
output.scrollTop = output.scrollHeight;
100108
}
@@ -119,6 +127,7 @@
119127
});
120128

121129
updateStatus();
130+
renderLog();
122131
requestAnimationFrame(() => {
123132
output.scrollTop = output.scrollHeight;
124133
});

app/templates/console.html

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ <h2>Recent commands</h2>
2929
</table>
3030
</section>
3131

32+
<script src="/static/ansi.js"></script>
3233
<script>
3334
(() => {
3435
const terminal = document.querySelector("#terminal");
@@ -51,18 +52,11 @@ <h2>Recent commands</h2>
5152
localStorage.setItem("terminalFontSize", String(terminalFontSize));
5253
}
5354

54-
function escapeHtml(text) {
55-
return text
56-
.replaceAll("&", "&amp;")
57-
.replaceAll("<", "&lt;")
58-
.replaceAll(">", "&gt;");
59-
}
60-
6155
function renderTerminal() {
6256
const beforeCaret = commandBuffer.slice(0, cursorIndex);
6357
const caretCharacter = commandBuffer[cursorIndex] || " ";
6458
const afterCaret = commandBuffer.slice(cursorIndex + 1);
65-
terminal.innerHTML = `${escapeHtml(committedText)}${escapeHtml(beforeCaret)}<span class="terminal-caret">${escapeHtml(caretCharacter)}</span>${escapeHtml(afterCaret)}`;
59+
terminal.innerHTML = `${rcloneRunnerAnsi.render(committedText)}${rcloneRunnerAnsi.render(beforeCaret)}<span class="terminal-caret">${rcloneRunnerAnsi.render(caretCharacter)}</span>${rcloneRunnerAnsi.render(afterCaret)}`;
6660
terminal.scrollTop = terminal.scrollHeight;
6761
}
6862

tests/test_job_preview.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,38 @@ def test_console_ctrl_v_pastes_clipboard_into_terminal():
259259
assert "sendInput(text);" in html
260260

261261

262+
def test_console_terminal_renders_ansi_sequences():
263+
html = templates.get_template("console.html").render(recent=[], recent_commands=[])
264+
js = Path("app/static/ansi.js").read_text()
265+
css = Path("app/static/styles.css").read_text()
266+
267+
assert '<script src="/static/ansi.js"></script>' in html
268+
assert "rcloneRunnerAnsi.render" in html
269+
assert "ansi-red" in js
270+
assert ".ansi-red" in css
271+
272+
273+
def test_log_viewer_renders_ansi_sequences():
274+
html = templates.get_template("_log_viewer.html").render(
275+
log_chunk=SimpleNamespace(
276+
text="\x1b[91mred\x1b[0m",
277+
next_before="",
278+
has_more=False,
279+
end_offset=12,
280+
),
281+
log_chunk_url="/logs/chunk",
282+
log_append_url="/logs/append",
283+
status_url=None,
284+
raw_log_url="/logs/raw",
285+
run=SimpleNamespace(status="success"),
286+
log_status_url=None,
287+
log_append_offset=12,
288+
)
289+
290+
assert '<script src="/static/ansi.js"></script>' in html
291+
assert "rcloneRunnerAnsi.render" in html
292+
293+
262294
def test_history_tables_use_styled_status_labels():
263295
job_run = SimpleNamespace(
264296
id=3,

0 commit comments

Comments
 (0)