| author | |
| committer | |
| log | bd99670fb91ecff5cbdb1a33ecbf927cdb3e6556 |
| tree | 23ce0378540ee06cf992ee1fd4cefa3163290620 |
| parent | 333b8a7a5c68ab8ab1917a6192022b0f96c46823 |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ |
closes #50, closes #31. the 0ms redraw timer batches write bursts, but
waiting on it is wrong in two cases: a buffer growing toward the engine
string length limit (flush at 64k, keeping the trailing partial line
buffered so a line under construction is not displayed mid-way), and a
timer starved by cpu-bound work blocking the event loop (a write made
50ms after a flush should have run flushes inline, so logs keep
streaming during long computations).3 files changed, 83 insertions(+), 1 deletions(-)
lib/log.test.ts+36| ... | ... | @@ -958,6 +958,42 @@ describe("log widgets", () => { |
| 958 | 958 | host.cancel(); |
| 959 | 959 | }); |
| 960 | 960 | |
| 961 | test("oversized buffers flush whole lines synchronously", () => { | |
| 962 | const host = new testing.MockScreen(); | |
| 963 | const line = "x".repeat(8191) + "\n"; | |
| 964 | for (let i = 0; i < 7; i += 1) host.writeOutput(line); | |
| 965 | ASSERT(host.stdout === "", "flushed before reaching the threshold"); | |
| 966 | // crossing the threshold flushes immediately, but the trailing line | |
| 967 | // under construction stays buffered | |
| 968 | host.writeOutput(line + "partial"); | |
| 969 | ASSERT(host.stdout === line.repeat(8), "whole lines were not flushed"); | |
| 970 | host.expectFrame(0, { stdout: line.repeat(8) + "partial" }); | |
| 971 | host.cancel(); | |
| 972 | }); | |
| 973 | ||
| 974 | test("single line larger than the flush threshold still flushes", () => { | |
| 975 | const host = new testing.MockScreen(); | |
| 976 | const giant = "y".repeat(70000); | |
| 977 | host.writeOutput(giant); | |
| 978 | // no newline is buffered, so memory is bounded by flushing mid-line | |
| 979 | host.expectFrame(null, { stdout: giant }); | |
| 980 | host.cancel(); | |
| 981 | }); | |
| 982 | ||
| 983 | test("starved redraw timer flushes on the next write", () => { | |
| 984 | const host = new testing.MockScreen(); | |
| 985 | host.writeOutput("one\n"); | |
| 986 | host.expectWithoutConsume(0); | |
| 987 | // simulate a blocked event loop: time advances but no timer fires | |
| 988 | host.timers.time += 30; | |
| 989 | host.writeOutput("two\n"); | |
| 990 | ASSERT(host.stdout === "", "flushed before the starvation threshold"); | |
| 991 | host.timers.time += 30; | |
| 992 | host.writeOutput("three\n"); | |
| 993 | host.expectFrame(null, { stdout: "one\ntwo\nthree\n" }); | |
| 994 | host.cancel(); | |
| 995 | }); | |
| 996 | ||
| 961 | 997 | test("node host patches and restores std streams", async () => { |
| 962 | 998 | const proc = UNWRAP(node.process); |
| 963 | 999 | const origOut = proc.stdout.write; |
lib/log.ts+44| ... | ... | @@ -447,6 +447,14 @@ interface WidgetState { |
| 447 | 447 | frameTime: number; |
| 448 | 448 | } |
| 449 | 449 | |
| 450 | // thresholds for flushing the log buffer outside the redraw timer. the 0ms | |
| 451 | // timer batches a synchronous burst of writes into one flush, but waiting on | |
| 452 | // it is wrong in two situations: the buffer growing toward the engine string | |
| 453 | // length limit, and a timer starved by cpu-bound work blocking the event | |
| 454 | // loop (it may never fire). | |
| 455 | const flushSyncBytes = 65536; | |
| 456 | const flushSyncMs = 50; | |
| 457 | ||
| 450 | 458 | /** |
| 451 | 459 | * terminal widget rendering is done by specifying all system APIs up front in |
| 452 | 460 | * an interface, creating an instance of the "widget host". |
| ... | ... | @@ -863,6 +871,42 @@ export function createTerminalWidgetHost( |
| 863 | 871 | if (last && last.err === err) last.text += chunk; |
| 864 | 872 | else buffer.push({ text: chunk, err }); |
| 865 | 873 | redrawSoon(0); |
| 874 | if (locks > 0 || rendering) return; | |
| 875 | if (buffer.reduce((n, c) => n + c.text.length, 0) >= flushSyncBytes) { | |
| 876 | flushSync(true); | |
| 877 | } else if (timer && now() >= redrawTime + flushSyncMs) { | |
| 878 | // the scheduled flush is long overdue, so the event loop is blocked; | |
| 879 | // flushing inline keeps logs streaming through cpu-bound work | |
| 880 | flushSync(false); | |
| 881 | } | |
| 882 | } | |
| 883 | ||
| 884 | /** | |
| 885 | * run the redraw flush immediately instead of waiting for the timer. with | |
| 886 | * `keepPartialLine`, text after the last buffered newline stays in the | |
| 887 | * buffer so a line under construction is not displayed mid-way; if the | |
| 888 | * buffer holds no newline at all, everything flushes to bound memory. | |
| 889 | */ | |
| 890 | function flushSync(keepPartialLine: boolean) { | |
| 891 | let tail: typeof buffer = []; | |
| 892 | if (keepPartialLine) { | |
| 893 | let i = buffer.length - 1; | |
| 894 | while (i >= 0 && !UNWRAP(buffer[i]).text.includes("\n")) i -= 1; | |
| 895 | if (i >= 0) { | |
| 896 | const edge = UNWRAP(buffer[i]); | |
| 897 | const split = edge.text.lastIndexOf("\n") + 1; | |
| 898 | tail = buffer.splice(i + 1); | |
| 899 | if (split < edge.text.length) { | |
| 900 | tail.unshift({ text: edge.text.slice(split), err: edge.err }); | |
| 901 | edge.text = edge.text.slice(0, split); | |
| 902 | } | |
| 903 | } | |
| 904 | } | |
| 905 | timer?.cancel(); | |
| 906 | timer = null; | |
| 907 | redrawCallback(); | |
| 908 | buffer = tail; | |
| 909 | if (tail.length) redrawSoon(0); | |
| 866 | 910 | } |
| 867 | 911 | |
| 868 | 912 | return { |
lib/readme.changes.md+3-1| ... | ... | @@ -17,7 +17,9 @@ |
| 17 | 17 | |
| 18 | 18 | - `mime`'s database contains `.eot` for embedded opentype fonts. |
| 19 | 19 | - `async.deferred` |
| 20 | - `log.writeError` writes to stderr with proper logic | |
| 20 | - `log` | |
| 21 | - `writeError` writes to stderr with proper logic. | |
| 22 | - buffering auto-flushes synchronously when it grows past 64k. | |
| 21 | 23 | |
| 22 | 24 | ## v4 |
| 23 | 25 |