authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-01-12 21:50:39-08:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-21 18:03:28-07:00
log76e57112a9715234e63ea77458888e56836e6286
treef7386f738942c940b8ab1c3e6c928e7355476958
parent5d86be6df8d71ae44648092495743af3856e7a43
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat(lib/log): messages without newlines + terminal lock

resolves #70 by renaming `writeLine` to `write`, which also meant reworking the message interface to support messages that do not end in a newline. this is a critical oversight that progress library needs. now, the global log or a progress nodes support this, which is great for unstructured data streams. additionally, the way the widget host works has been drastically altered, it works off of a terminal lock. this implements the vision in #65. in the future, i'll make it so interweaving scopes can display cleanly.

6 files changed, 695 insertions(+), 238 deletions(-)

lib/log.test.ts+274-18
......@@ -5,21 +5,135 @@ log satisfies log.Scope;
55// implementation that buffers all bytes in memory. it is a great
66// example of how modular the entire system is.
77describe("log widgets", () => {
8 test("writeLine buffers", () => {
9 using host = new testing.MockScreen();
10 host.writeLine("hello world");
11 host.writeLine("and so on");
8 test("write buffering", () => {
9 const host = new testing.MockScreen();
10 host.write("hello world ");
11 host.write("and so on\n");
1212 host.expectFrame(0, {
13 stdout: "hello world\nand so on\n",
13 stdout: "hello world and so on\n",
1414 });
15 host.writeLine("more logs");
15 host.write("more logs\n");
1616 host.expectFrame(0, {
1717 stdout: "more logs\n",
1818 });
19 host.write("abc def");
20 {
21 const unlock = host.getDrawLock();
22 host.expectFrame(null, {
23 stdout: "abc def",
24 });
25 unlock();
26 }
27 host.expectFrame(null, { stdout: "" });
28 {
29 const unlock = host.getDrawLock();
30 host.write("abc def ");
31 host.write("hijk");
32 unlock();
33 host.expectFrame(0, {
34 stdout: "abc def hijk",
35 });
36 }
37 host.expectFrame(null, { stdout: "" });
38 host.cancel();
39 });
40
41 test("widget partial line", () => {
42 const host = new testing.MockScreen();
43 const wd1: log.Widget = { format: (now) => `t=${now}` };
44 const w1 = host.startWidget(wd1);
45 host.expectFrame(0, {
46 stderr: testing.MockScreen.sync([
47 "t=0\n",
48 ]),
49 });
50 host.expectNone();
51 host.write("partial line");
52 host.expectFrame(0, {
53 merged: testing.MockScreen.sync([
54 ansi.cursorUp(1),
55 ansi.clearFullLine,
56 "partial line",
57 ansi.cursorSave,
58 "\n",
59 "t=0\n",
60 ]),
61 stdout: "partial line",
62 stderr: testing.MockScreen.sync([
63 ansi.cursorUp(1),
64 ansi.clearFullLine,
65 ansi.cursorSave,
66 "\n",
67 "t=0\n",
68 ]),
69 });
70 host.write(" and more\n");
71 host.expectFrame(0, {
72 merged: testing.MockScreen.sync([
73 ansi.cursorRestore,
74 " and more\n",
75 "t=0\n",
76 ]),
77 stdout: " and more\n",
78 stderr: testing.MockScreen.sync([
79 ansi.cursorRestore,
80 "t=0\n",
81 ]),
82 });
83 host.cancel();
84 });
85
86 test("widget partial line 2", () => {
87 const host = new testing.MockScreen();
88 const wd1: log.Widget = { format: (now) => `abc\ndefg` };
89 const w1 = host.startWidget(wd1);
90 host.expectFrame(0, {
91 stderr: testing.MockScreen.sync([
92 "abc\ndefg\n",
93 ]),
94 });
95 // abc
96 // defg
97 host.expectNone();
98 host.write("partial line");
99 // abc
100 // defg
101 // [c]
102 host.expectFrame(0, {
103 merged: testing.MockScreen.sync([
104 ansi.cursorUp(2),
105 ansi.clearFullLine,
106 "partial line",
107 ansi.cursorSave + "\n",
108 "abc",
109 ansi.clearToEndOfLine,
110 "\ndefg\n",
111 ]),
112 });
113 host.write(" and more\na");
114 // partial line[T]
115 // abc
116 // defg
117 // [CURSOR]
118 // move up 3, right 12
119 host.expectFrame(0, {
120 merged: testing.MockScreen.sync([
121 ansi.cursorUp(2),
122 ansi.clearFullLine,
123 ansi.cursorRestore,
124 " and more\n",
125 "a",
126 ansi.cursorSave + "\n",
127 "abc",
128 ansi.clearToEndOfLine,
129 "\ndefg\n",
130 ]),
131 });
132 host.cancel();
19133 });
20134
21135 test("widget with interweaving logs", () => {
22 using host = new testing.MockScreen();
136 const host = new testing.MockScreen();
23137
24138 using _ = host.startWidget({
25139 format: (now) => `line one ${now}\nline two`,
......@@ -27,15 +141,13 @@ describe("log widgets", () => {
27141
28142 host.expectFrame(0, {
29143 stderr: testing.MockScreen.sync([
30 ansi.startOfLine,
31144 "line one 0\nline two\n",
32145 ]),
33146 });
34147
35 host.writeLine("info: line of log");
148 host.write("info: line of log\n");
36149 host.expectFrame(0, {
37150 merged: testing.MockScreen.sync([
38 ansi.startOfLine,
39151 ansi.cursorUp(2),
40152 ansi.clearFullLine,
41153 "info: line of log\n",
......@@ -43,11 +155,10 @@ describe("log widgets", () => {
43155 ]),
44156 });
45157
46 host.writeLine("warn: line of log");
47 host.writeLine("error: line of log");
158 host.write("warn: line of log\n");
159 host.write("error: line of log\n");
48160 host.expectFrame(0, {
49161 merged: testing.MockScreen.sync([
50 ansi.startOfLine,
51162 ansi.cursorUp(1),
52163 ansi.clearFullLine,
53164 ansi.cursorUp(1),
......@@ -57,16 +168,112 @@ describe("log widgets", () => {
57168 "line one 0\nline two\n",
58169 ]),
59170 });
171 host.cancel();
172 });
173
174 test("temporary drawing lock", () => {
175 const host = new testing.MockScreen();
176
177 using _ = host.startWidget({
178 format: (now) => `line one ${now}\nline two`,
179 });
180
181 host.expectFrame(0, {
182 stderr: testing.MockScreen.sync([
183 "line one 0\nline two\n",
184 ]),
185 });
186
187 host.write("info: line of log\n");
188 host.expectFrame(0, {
189 merged: testing.MockScreen.sync([
190 ansi.cursorUp(2),
191 ansi.clearFullLine,
192 "info: line of log\n",
193 "line one 0\nline two\n",
194 ]),
195 });
196
197 {
198 host.write("warn: line of log\n");
199 host.expectWithoutConsume(0);
200 const unlock = host.getDrawLock("short");
201 // info: line of log
202 // line one 0
203 // line two
204 // [C]
205 host.expectFrame(null, {
206 merged: [
207 ansi.syncStart,
208 ansi.cursorUp(1),
209 ansi.clearFullLine,
210 ansi.cursorUp(1),
211 ansi.clearFullLine,
212 "warn: line of log\n",
213 ].join(""),
214 stdout: "warn: line of log\n",
215 });
216 const unlock2 = host.getDrawLock("long");
217 host.expectNone();
218 host.write("error: line of log\n");
219 host.expectNone();
220 unlock2();
221 host.expectNone();
222 unlock();
223 // warn: line of log
224 // [c]
225 host.expectFrame(0, {
226 merged: [
227 "error: line of log\n",
228 "line one 0\nline two\n",
229 ansi.syncEnd,
230 ].join(""),
231 });
232 host.cancel();
233 }
234 {
235 const unlock = host.getDrawLock("short");
236 // info: line of log
237 // line one 0
238 // line two
239 // [C]
240 host.expectFrame(null, {
241 merged: [
242 ansi.syncStart,
243 ansi.cursorUp(1),
244 ansi.clearFullLine,
245 ansi.cursorUp(1),
246 ansi.clearFullLine,
247 ].join(""),
248 stdout: "",
249 });
250 const unlock2 = host.getDrawLock("long");
251 host.expectNone();
252 unlock2();
253 host.expectNone();
254 unlock();
255 // warn: line of log
256 // [c]
257 host.expectFrame(0, {
258 merged: [
259 "line one 0\nline two\n",
260 ansi.syncEnd,
261 ].join(""),
262 });
263 host.cancel();
264 }
60265 });
61266
62 test("widget with logs in same frame", () => {
63 using host = new testing.MockScreen();
267 test("widget expires", () => {
268 const host = new testing.MockScreen();
64269
65 host.writeLine("log line 1");
66 host.writeLine("log line 2");
270 host.write("log line 1\n");
271 host.write("log line 2\n");
67272
68273 using _ = host.startWidget({
69 format: (now) => `widget line one ${now}\nwidget line two`,
274 format: (now) =>
275 now > 1000 ? null : `widget line one ${now}\nwidget line two`,
276 fps: 1,
70277 });
71278
72279 host.expectFrame(0, {
......@@ -77,6 +284,55 @@ describe("log widgets", () => {
77284 "widget line one 0\nwidget line two\n",
78285 ]),
79286 });
287 host.expectFrame(1000, {
288 merged: testing.MockScreen.sync([
289 ansi.cursorUp(2),
290 "widget line one 1000\nwidget line two\n",
291 ]),
292 });
293 host.expectFrame(1000, {
294 merged: testing.MockScreen.sync([
295 ansi.cursorUp(1),
296 ansi.clearFullLine,
297 ansi.cursorUp(1),
298 ansi.clearFullLine,
299 ]),
300 });
301 host.cancel();
302 });
303
304 test("widget expires partial line output", () => {
305 const host = new testing.MockScreen();
306
307 host.write("log line;");
308
309 using _ = host.startWidget({
310 format: (now) =>
311 now > 500 ? null : `widget line one ${now}\nwidget line two`,
312 fps: 1,
313 });
314
315 host.expectFrame(0, {
316 stdout: "log line;",
317 merged: testing.MockScreen.sync([
318 "log line;",
319 ansi.cursorSave + "\n",
320 "widget line one 0\nwidget line two\n",
321 ]),
322 });
323 host.write(" rest of line\n");
324 host.expectFrame(1000, {
325 stdout: " rest of line\n",
326 merged: testing.MockScreen.sync([
327 ansi.cursorUp(1),
328 ansi.clearFullLine,
329 ansi.cursorUp(1),
330 ansi.clearFullLine,
331 ansi.cursorRestore,
332 " rest of line\n",
333 ]),
334 });
335 host.cancel();
80336 });
81337});
82338
lib/log.ts+323-146
......@@ -1,9 +1,11 @@
11/**
22 * by using `lib/log.ts`, an application gets easy scoped logging as well as
3 * integration with terminal widgets such as `lib/progress.ts`.
3 * integration with terminal widgets such as `lib/progress.ts`. even when these
4 * widgets are active, using the logging interface is optional; global I/O with
5 * `console.*` and `process.std{out/err}` are patched to play nice.
46 *
57 * the pattern for using this module is to shadow the global `console` with a
6 * per-file logging scope:
8 * per-file logging scope, which makes it impossible to use the wrong logger.
79 *
810 * ```ts
911 * import * as log from "@clo/lib/log";
......@@ -21,17 +23,19 @@
2123 * now, the code reads familiarly (`console.log` is universally understood),
2224 * but the output is organized into relevant scopes.
2325 *
24 * in addition to static log messages, a system for interactive I/O
25 * {@linkcode Widget} is provided by calling {@linkcode startWidget}. these
26 * allow showing temporary or interactive information, such as program status
27 * or input prompts. a powerful example of this system in action is
28 * `lib/progress.ts`, which uses a log widget by default to show status.
26 * in addition to static log messages, a system for interactive I/O via the
27 * {@linkcode Widget} interface can be started with {@linkcode startWidget}.
28 * these allow showing temporary or interactive information, such as program
29 * status or input prompts. a powerful example of this system in action is
30 * `lib/progress.ts`, which uses a widget as it's default rendering backend.
2931 * (TODO: widgets cannot recieve "input" data yet)
3032 *
31 * this module offers two environment integrations:
33 * `lib/log.ts` offers two environment integrations:
3234 * - in node.js, log messages are colored depending on the level and show
33 * widgets directly under the long using ANSI cursor controls.
34 * - otherwise, logs are surfaced using the global `console` API
35 * widgets directly under the long using ANSI cursor controls. when the
36 * terminal is not a TTY, widgets are silent.
37 * - in browsers and other, logs are surfaced using the global `console` API
38 * and widgets are disabled.
3539 *
3640 * custom log integrations can be built on top of this module by calling
3741 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
......@@ -72,6 +76,11 @@ export interface Scope {
7276 tee(writer: (message: Message) => void): ts.Dispose;
7377}
7478
79export interface RootScope extends Scope {
80 /** write a partial line to the output. */
81 write(text: string): void;
82}
83
7584export const originalLogArgs = Symbol("originalLogArgs");
7685export interface Message {
7786 level: "error" | "warn" | "info" | "debug";
......@@ -85,6 +94,8 @@ export interface Message {
8594 stack?: stack.Frame[];
8695 /** arbitrary data from the logging source. */
8796 custom?: Partial<Record<string, ts.Json>>;
97 /** print a newline at the end of this log line? */
98 newline?: boolean;
8899 /**
89100 * original logging arguments, if present. this field is indexed by a symbol
90101 * so that it is lost during JSON serialization, as callers are allowed to log
......@@ -140,6 +151,7 @@ export function replaceGlobalFormatFunction(
140151 globalMessageFormatFunction = format;
141152}
142153
154/** includes the trailing newline for standard log messages */
143155export function formatMessage(msg: Message, colors: boolean): string {
144156 return globalMessageFormatFunction(msg, colors);
145157}
......@@ -169,21 +181,23 @@ export function startWidget(widget: Widget): ts.Dispose {
169181 * no built-in prefix or formatting. ensures the text does not interweave. data
170182 * will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
171183 */
172export function writeLine(text: string) {
173 globalWidgetHost.writeLine(text);
184export function write(text: string) {
185 globalWidgetHost.write(text);
174186}
175187
176/** write a Message object directly. */
188/** write a {@linkcode Message} object directly. */
177189export function writeMessage(m: Message) {
178190 globalLog.writeMessage(m);
179191}
180192
181193/**
182 * while locked, no widgets will draw. prefer `writeLine`.
183 * this lock is not exclusive.
194 * while locked, no widgets will draw. prefer calling `write` to opt into
195 * automatic buffering. this lock is not exclusive. if the lock will be held
196 * for an extremely short amount of time, pass `"short"` which will allow
197 * more optimized use of ansi synchronization codes.
184198 */
185export function getDrawLock(): ts.Dispose {
186 return globalWidgetHost.getDrawLock();
199export function getDrawLock(mode: "long" | "short"): ts.Dispose {
200 return globalWidgetHost.getDrawLock(mode);
187201}
188202
189203export function headlessScope(dispatch: DispatchFunction): Scope {
......@@ -196,11 +210,13 @@ export interface Widget {
196210 * return the widget's text. return null to detach the widget.
197211 * may get called more often than the specified `fps`.
198212 * supports color codes but not ansi cursor movements.
199 */ format(
213 */
214 format(
200215 now: ReturnType<typeof performance.now>,
201216 ):
202217 | string
203 | null; /** 'null' to never update (use 'onChange'). defaults to 12 fps */
218 | null;
219 /** 'null' to never update (use 'onChange') */
204220 fps?:
205221 | number
206222 | null; /** Subscribe to manual widget updates. Call `rerender` when needed. */
......@@ -211,26 +227,49 @@ export interface Widget {
211227
212228/** {@linkcode widgetHost}'s input takes terminal i/o as well as timing APIs */
213229export interface HeadlessWidgetEnv {
214 /** recieves ANSI escape sequences for interactive data */
215 writeInteractive(text: string): void;
216 /** recieves log content (from `writeLine`) */
217 writeOutput(text: string): void;
230 /**
231 * an exclusive lock on the terminal is held whenever widgets are active. a
232 * secondary purpose of this is to instrument/deinstrument other code to
233 * integrate with `log.ts`'s widget lock. for example, the node.js adapter
234 * will patch `process.std{out,err}` to ensure write calls properly get a
235 * draw lock.
236 *
237 * this lock can be cleared by deactiving all widgets, or by calling
238 * {@linkcode getDrawLock}.
239 *
240 * currently, this lock must be able to be synchronously aquired at any
241 * point. if you desire an async locking function, please contact me so we
242 * can design how it would work. you can currently work around this with
243 * `getDrawLock`
244 */
245 lockTerminal: () => TerminalLock;
246 /** fast path for writing output without widgets */
247 writeOutputTemporaryLock?: (buffer: string) => void;
218248 /** monotonic milliseconds */
219 now(): ReturnType<typeof performance.now>;
249 now: () => ReturnType<typeof performance.now>;
220250 /** after resolving, `now()` should have increased by the delay time */
221251 delay: typeof async.delay;
222 /** called often. */
252}
253
254export interface TerminalLock {
255 /** recieves ANSI escape sequences for interactive data (should flush immediately) */
256 writeInteractive(text: string): void;
257 /** recieves log content from `write` (pre-buffered; should flush immediately) */
258 writeOutput(text: string): void;
259 /** called often. TODO: convert this into a subscription */
223260 getSize(): { columns: number; rows: number };
224 /** called to enable input events */
225 onInput?(write: (bytes: Uint8Array | string) => void): () => void;
261 /** temporarily free the lock */
262 temporaryUnlock?(): () => void;
263 /** completely free the lock */
264 close(): void;
226265}
227266
228267/** an implementation of an ANSI-based widget host */
229268export interface HeadlessWidgetHost {
230269 /** see the top-level {@linkcode writeLine} function */
231 writeLine(text: string): void;
270 write(text: string): void;
232271 /** see the top-level {@linkcode getDrawLock} function */
233 getDrawLock(): ts.Dispose;
272 getDrawLock(mode: "long" | "short"): ts.Dispose;
234273 /** see the top-level {@linkcode startWidget} function */
235274 startWidget(widget: Widget): ts.Dispose;
236275 /** stop all widgets and remove all timers. */
......@@ -258,13 +297,13 @@ interface WidgetState {
258297 * - Maximum of one `wait` call at once. When the expected time suddenly
259298 * shrinks, the timer is rescheduled.
260299 * - When redrawing widget lines, three tricks are done to reduce flickering:
261 * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd)
300 * 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd).
262301 * 2. A simple prefix-based diffing algorithm for skipping unchanged text
263302 * 3. Avoid clearing a line before redrawing it.
264303 * Points 2 and 3 are used for terminals that are slow or do not support sync.
265304 */
266305export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
267 const { writeOutput, writeInteractive, now, delay, getSize } = env;
306 const { lockTerminal, now, delay, writeOutputTemporaryLock } = env;
268307
269308 let timer: async.Cancelable<void> | null = null;
270309
......@@ -272,113 +311,146 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
272311 let redrawTime = 0;
273312 let lastFlush = 0;
274313 let buffer = "";
314 let partialLine = false;
275315 const widgets: Widget[] = [];
276316 const internals: WidgetState[] = [];
277317 let lines: string[] = [];
318 let hasSyncStart = false;
319 let terminal: TerminalLock | null = null;
320 let tempUnlock: (() => void) | null = null;
278321
279322 function redrawCallback() {
280323 timer = null;
281324 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround
282325
326 // trivial path when not using widgets
283327 if (!lines.length && !widgets.length) {
284 buffer && writeOutput(buffer);
328 ASSERT(buffer);
329 if (writeOutputTemporaryLock) {
330 writeOutputTemporaryLock(buffer);
331 if (hasSyncStart) {
332 terminal ??= lockTerminal();
333 terminal.writeInteractive(ansi.syncEnd);
334 }
335 } else {
336 terminal ??= lockTerminal();
337 buffer && terminal.writeOutput(buffer);
338 if (hasSyncStart) {
339 terminal ??= lockTerminal();
340 terminal.writeInteractive(ansi.syncEnd);
341 }
342 terminal.close();
343 terminal = null;
344 }
285345 buffer = "";
286346 return;
287347 }
288348
289 const { columns, rows } = getSize();
349 terminal ??= lockTerminal();
350
351 const { columns, rows } = terminal.getSize();
290352 let newWidgetLines: string[] = [];
291353 let next = Infinity;
292 if (widgets[0]) {
293 for (let w = 0, { length } = widgets; w < length; w += 1) {
294 const outText = UNWRAP(widgets[w]).format(lastFlush);
295 if (!outText) {
296 widgets.splice(w, 1);
297 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
298 w -= 1;
299 length -= 1;
300 continue;
301 }
302 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
303 if (rowsLeft === 1) break;
304 const lines = outText.split("\n").slice(0, rowsLeft);
305 newWidgetLines.push(
306 ...lines.map((line) => ansi.trimToWidth(line, columns - 1)),
307 );
308
309 next = Math.min(next, UNWRAP(internals[w]).frameTime);
354 for (let w = 0, { length } = widgets; w < length; w += 1) {
355 const outText = UNWRAP(widgets[w]).format(lastFlush);
356 if (!outText) {
357 widgets.splice(w, 1);
358 UNWRAP(internals.splice(w, 1)[0]).unsub?.();
359 w -= 1;
360 length -= 1;
361 continue;
310362 }
363 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
364 if (rowsLeft === 1) break;
365 const lines = outText.split("\n").slice(0, rowsLeft);
366 newWidgetLines.push(
367 ...lines.map((line) => ansi.trimToWidth(line, columns - 1)),
368 );
311369
312 newWidgetLines = newWidgetLines.slice(0, rows - 1);
370 next = Math.min(next, UNWRAP(internals[w]).frameTime);
313371 }
314
372 newWidgetLines = newWidgetLines.slice(0, rows - 1);
315373 if (next < Infinity) redrawSoon(next);
316374
317375 if (!newWidgetLines[0]) {
318 if (lines.length) {
319 let clearLinesTop = Math.min(
320 lines.length,
321 string.countNewlines(buffer),
322 );
323 writeInteractive(
324 ansi.startOfLine +
325 // skip up the shared widget space
326 ansi.cursorUp(lines.length) +
327 // clear the lines to contain `buffer`
328 (ansi.clearFullLine + ansi.startOfNextLine)
329 .repeat(clearLinesTop) +
330 ansi.cursorUp(clearLinesTop),
376 if (lines.length > 0) {
377 terminal.writeInteractive(
378 (hasSyncStart ? "" : ansi.syncStart) +
379 // clear the widget space
380 (ansi.cursorUp(1) + ansi.clearFullLine)
381 .repeat(lines.length) +
382 (partialLine ? ansi.cursorRestore : ""),
331383 );
384 hasSyncStart = true;
332385 lines = [];
333386 }
334 buffer && writeOutput(buffer);
387 if (buffer) terminal.writeOutput(buffer);
335388 buffer = "";
389 if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd);
336390 return;
337391 }
338392
339393 if (buffer) {
340 // do not perform diffing since the entire screen is moving down
341 // TODO: should lib/log handle wrapping?
342 let clearLinesTop = Math.min(
394 // when writing a buffer alongside widgets, the screen may look like this
395 // > [existing log]
396 // > [optional partial line]
397 // > [widget line 1]
398 // > [widget line 2]
399 // > [widget line 3]
400 // > [cursor is start of this line]
401 //
402 // first, clear out the space where new lines are going to intersect
403 const createsPartialLine = !buffer.endsWith("\n");
404 // if more lines are buffered than there are widgets, only some are needed
405 const clearLinesTop = Math.min(
343406 lines.length,
344 string.countNewlines(buffer),
407 string.countNewlines(buffer) +
408 (createsPartialLine ? 1 : 0) +
409 (partialLine ? -1 : 0),
345410 );
346411 const oldLines = lines.slice(clearLinesTop);
347 writeInteractive(
348 ansi.syncStart + (clearLinesTop > 0
349 ? ansi.startOfLine +
350 // skip up the shared widget space
351 ansi.cursorUp(lines.length - clearLinesTop + 1) +
352 // clear the lines to contain `buffer`
353 ansi.clearFullLine +
354 (ansi.cursorUp(1) + ansi.clearFullLine)
355 .repeat(clearLinesTop - 1)
356 : ""),
412 terminal.writeInteractive(
413 (hasSyncStart ? "" : ansi.syncStart) +
414 ((clearLinesTop > 0 || partialLine)
415 // clear the lines for buffer
416 ? (clearLinesTop > 0
417 ? ansi.cursorUp(lines.length - clearLinesTop + 1) +
418 ansi.clearFullLine +
419 (ansi.cursorUp(1) + ansi.clearFullLine)
420 .repeat(clearLinesTop - 1)
421 : "") +
422 (partialLine ? ansi.cursorRestore : "")
423 : ""),
357424 );
425 partialLine = createsPartialLine;
358426 // then write output lines on standard out
359 writeOutput(buffer);
360 writeInteractive(
361 // the widget text
362 newWidgetLines.map((newLine, i) =>
363 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
364 ? newLine + ansi.reset
365 : newLine) +
366 // clear rest of line if needed
367 (oldLines[i] &&
368 ansi.widthInTerminal(oldLines[i]) >
369 ansi.widthInTerminal(newLine)
370 ? ansi.clearToEndOfLine
371 : "") +
372 "\n"
373 ).join("") + ansi.syncEnd,
427 terminal.writeOutput(buffer);
428 terminal.writeInteractive(
429 // if a partial line is created, then the widgets
430 // have to go on the next line, to avoid breaking stdout,
431 // the newline gets emitted on the interactive out.
432 (createsPartialLine ? ansi.cursorSave + "\n" : "") +
433 // the widget text
434 newWidgetLines.map((newLine, i) =>
435 (newLine.includes("\x1b") && !newLine.endsWith(ansi.reset)
436 ? newLine + ansi.reset
437 : newLine) +
438 // clear rest of line if needed
439 (oldLines[i] &&
440 ansi.widthInTerminal(oldLines[i]) >
441 ansi.widthInTerminal(newLine)
442 ? ansi.clearToEndOfLine
443 : "") +
444 "\n"
445 ).join("") + ansi.syncEnd,
374446 );
375447 } else {
376448 const clearLinesBottom = Math.min(
377449 lines.length,
378450 Math.max(0, lines.length - (newWidgetLines?.length ?? 0)),
379451 );
380 writeInteractive(
381 ansi.syncStart + ansi.startOfLine +
452 terminal.writeInteractive(
453 (hasSyncStart ? "" : ansi.syncStart) +
382454 // clear the bottom lines
383455 (clearLinesBottom
384456 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine)
......@@ -402,6 +474,7 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
402474 ansi.syncEnd,
403475 );
404476 }
477 hasSyncStart = false;
405478 lines = newWidgetLines;
406479
407480 buffer = "";
......@@ -420,55 +493,87 @@ export function headlessWidgetHost(env: HeadlessWidgetEnv): HeadlessWidgetHost {
420493 timer.then(redrawCallback);
421494 }
422495
423 function flushAndClear() {
496 function flushAndClear(shortTermDrawLock: boolean) {
424497 timer?.cancel();
425498 timer = null;
426499 if (lines.length > 0) {
427 // clear widgets
500 UNWRAP(terminal).writeInteractive(
501 ansi.syncStart +
502 // clear the widget space
503 (ansi.cursorUp(1) + ansi.clearFullLine)
504 .repeat(lines.length) +
505 (partialLine ? ansi.cursorRestore : "") +
506 (shortTermDrawLock ? "" : ansi.syncEnd),
507 );
508 lines = [];
509 hasSyncStart = shortTermDrawLock;
510 }
511 if (buffer.length > 0) {
512 if (widgets.length === 0 && writeOutputTemporaryLock) {
513 writeOutputTemporaryLock(buffer);
514 } else {
515 terminal ??= lockTerminal();
516 terminal.writeOutput(buffer);
517 if (widgets.length === 0) {
518 terminal.close();
519 terminal = null;
520 }
521 }
522 buffer = "";
428523 }
429 if (buffer.length > 0) writeOutput(buffer), buffer = "";
430524 }
431525
432526 return {
433 writeLine(chunk) {
434 buffer += chunk + "\n";
435 redrawSoon(0);
527 write(chunk) {
528 if (chunk) buffer += chunk, redrawSoon(0);
436529 },
437 getDrawLock() {
438 if (locks === 0) flushAndClear();
530 getDrawLock(mode) {
531 if (locks === 0) {
532 flushAndClear(mode === "short");
533 if (widgets.length > 0 && terminal) {
534 if (terminal.temporaryUnlock) {
535 tempUnlock = terminal.temporaryUnlock();
536 } else {
537 terminal.close();
538 terminal = null;
539 }
540 }
541 }
439542 locks += 1;
440543 return ts.defer(() => {
441544 locks -= 1;
442545 if (locks === 0) {
443 if (buffer.length > 0) writeOutput(buffer), buffer = "";
444 // TODO: reinit widget
546 tempUnlock?.();
547 tempUnlock = null;
548 if (buffer.length > 0 || widgets.length > 0) redrawSoon(0);
445549 }
446550 });
447551 },
448552 startWidget(w) {
449 if (!widgets.includes(w)) {
450 const state: WidgetState = {
451 next: 0,
452 unsub: null,
453 frameTime: 1000 / (w.fps ?? 0),
454 };
455 widgets.push(w);
456 internals.push(state);
457 state.unsub = w.onChange?.(() => {
458 state.next = 0;
459 redrawSoon(0);
460 }) ?? null;
553 ASSERT(!widgets.includes(w), "Cannot start the same widget twice.");
554 const state: WidgetState = {
555 next: 0,
556 unsub: null,
557 frameTime: 1000 / (w.fps ?? 0),
558 };
559 widgets.push(w);
560 internals.push(state);
561 state.unsub = w.onChange?.(() => {
562 state.next = 0;
461563 redrawSoon(0);
462 }
564 }) ?? null;
565 redrawSoon(0);
463566 return ts.defer(() => {
464567 const i = widgets.indexOf(w);
568 if (i === -1) return;
465569 widgets.splice(i, 1);
466570 UNWRAP(internals.splice(i, 1)[0]).unsub?.();
467571 redrawSoon(0);
468572 });
469573 },
470574 cancel() {
471 flushAndClear();
575 flushAndClear(false);
576 widgets.splice(0, widgets.length);
472577 },
473578 delay,
474579 now,
......@@ -500,7 +605,7 @@ let withinDispatch = false;
500605let withinStackCapture = false;
501606
502607/** this class is an implementation detail */
503const ScopeImpl = class Scope implements Scope {
608const ScopeImpl = class Scope implements RootScope {
504609 name: string | undefined;
505610 // TODO: this abstraction implementation has low performance. making every
506611 // scope define it's own dispatch is needed to correctly implement `tee`.
......@@ -561,6 +666,23 @@ const ScopeImpl = class Scope implements Scope {
561666 withinDispatch = false;
562667 };
563668
669 write: (text: string) => void = (text) => {
670 let frames;
671 if (!withinStackCapture) {
672 withinStackCapture = true;
673 frames = stack.capture().slice(2);
674 withinStackCapture = false;
675 }
676 this.writeMessage({
677 level: "info",
678 scope: "",
679 newline: false,
680 text,
681 time: Date.now(),
682 stack: frames,
683 });
684 };
685
564686 scoped(name: string): Scope {
565687 const current = this.name;
566688 return new Scope(
......@@ -584,16 +706,17 @@ const globalWidgetHost = /* @__PURE__ */ (() => {
584706 // return a no-op
585707 let warned = false;
586708 return {
587 writeLine: (line: string) => console.log(line),
709 write: (line: string) => console.log(line),
588710 getDrawLock: () => ts.defer(() => {}),
589711 startWidget: (w: Widget) => {
590712 if (!warned) {
591713 console.warn(
592714 '"@clo/lib/log.ts"\'s startWidget was called in an environment ' +
593 "that does not support the Node.js 'process' API. Widgets" +
715 "that does not support the Node.js 'process' API. Widgets " +
594716 "will not be visible.",
595717 );
596718 }
719 warned = true;
597720 const close = w.onChange?.(() => {});
598721 return ts.defer(close ?? (() => {}));
599722 },
......@@ -601,29 +724,76 @@ const globalWidgetHost = /* @__PURE__ */ (() => {
601724 };
602725 }
603726 const widget = headlessWidgetHost({
604 writeOutput: (string) => process.stdout.write(string),
605 writeInteractive: (string) => process.stderr.write(string),
727 lockTerminal() {
728 const { stdout, stderr } = process;
729 let disposed = false;
730
731 function patch<T, A extends unknown[]>(
732 fn: (this: T, ...args: A) => void,
733 ) {
734 return function (this: T, ...args: A) {
735 using _ = disposed ? null : widget.getDrawLock();
736 fn.apply(this, args);
737 };
738 }
739
740 // patch calls to `process.std{out,err}`
741 // note: `pipe` uses managed calls to `write`, so this is plenty
742 const stdoutWrite = stdout.write;
743 const stderrWrite = stderr.write;
744 const stdoutEnd = stdout.end;
745 const stderrEnd = stderr.end;
746 const newStdoutWrite = stdout.write = patch(
747 stdoutWrite === node.builtin("stream")?.Writable.prototype.write
748 ? widget.write
749 : stdoutWrite,
750 );
751 const newStderrWrite = stderr.write = patch(stderrWrite);
752 const newStdoutEnd = stdout.end = patch(stdoutEnd);
753 const newStderrEnd = stderr.end = patch(stderrEnd);
754
755 // non-node runtimes will typically implement console in a way that
756 // doesn't use `node:process`, so it must also get patched
757 const console = globalThis
758 .console as unknown as Record<string, () => void>;
759 const restoreConsole: [string, old: () => void, patch: () => void][] = [];
760 for (const [key, old] of Object.entries(console)) {
761 if (typeof old !== "function") continue;
762 try {
763 const patched = console[key] = patch(old);
764 restoreConsole.push([key, old, patched]);
765 } catch { /* skip */ }
766 }
767
768 return {
769 writeOutput: (string) => stdoutWrite.call(stdout, string),
770 writeInteractive: (string) => stderrWrite.call(stderr, string),
771 getSize: () => process.stderr,
772 temporarilyUnlock() {
773 // no action needed
774 },
775 close() {
776 disposed = true;
777 // leave patches in place if something else tampered with it.
778 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
779 if (stderr.write === newStderrWrite) stdout.write = stderrWrite;
780 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
781 if (stderr.end === newStderrEnd) stdout.end = stderrEnd;
782 for (const [key, old, patched] of restoreConsole) {
783 if (console[key] === patched) console[key] = old;
784 }
785 },
786 };
787 },
788 writeOutputTemporaryLock(buffer: string) {
789 process.stdout.write(buffer);
790 },
606791 now: () => performance.now(),
607792 delay: async.delay,
608 getSize: () => process.stderr,
609793 });
610794 process.addListener("beforeExit", () => widget.cancel());
611795 process.addListener("exit", () => widget.cancel());
612796
613 // Make sure the default `console` will not interweave with widgets
614 try {
615 const console = globalThis.console as unknown as Record<string, Function>;
616 for (const key of Object.keys(console)) {
617 const fn = console[key];
618 if (typeof fn === "function") {
619 console[key] = function (...args: unknown[]) {
620 using _ = getDrawLock();
621 fn.apply(this, args);
622 };
623 }
624 }
625 } catch {}
626
627797 return widget;
628798})();
629799
......@@ -635,7 +805,7 @@ const levelToAnsi: Record<Message["level"], string> = {
635805};
636806
637807let globalMessageFormatFunction: MessageFormatFunction = (
638 { level, scope, text },
808 { level, scope, text, newline },
639809 colors,
640810) => {
641811 if (!text) return "";
......@@ -648,7 +818,7 @@ let globalMessageFormatFunction: MessageFormatFunction = (
648818 : scope
649819 ? `${level}(${scope}): `
650820 : `${level}: `;
651 return prefix + text;
821 return prefix + text + (newline !== false ? "\n" : "");
652822};
653823let globalOutputFunction!: DispatchFunction;
654824const globalLog = /* @__PURE__ */ (() => {
......@@ -656,7 +826,7 @@ const globalLog = /* @__PURE__ */ (() => {
656826 globalOutputFunction = node.process
657827 // In Node.js, coordinate with the widget host
658828 ? (message) => {
659 globalWidgetHost.writeLine(globalMessageFormatFunction(message, colors));
829 globalWidgetHost.write(globalMessageFormatFunction(message, colors));
660830 }
661831 // Otherwise, forward to `console`
662832 : (m) => {
......@@ -673,6 +843,13 @@ const globalLog = /* @__PURE__ */ (() => {
673843})();
674844
675845export type DispatchFunction = (message: Message) => void;
846/**
847 * agnostic to the backend. should be able to format `newline: false`
848 * messages without a trailing newline, but not all backends may
849 * support this.
850 *
851 * TODO: change this to a `write`+`flush` pattern?
852 */
676853export type MessageFormatFunction = (
677854 message: Message,
678855 colors: boolean,
......@@ -684,4 +861,4 @@ import * as node from "./node.ts";
684861import * as stack from "./log/stack.ts";
685862import * as string from "./string.ts";
686863import * as ts from "./ts.ts";
687import { UNWRAP } from "./assert.ts";
864import { ASSERT, UNWRAP } from "./assert.ts";
lib/node.ts+8
......@@ -27,6 +27,7 @@ interface Tty {
2727 columns: number;
2828 rows: number;
2929 addListener(event: string, callback: () => void): this;
30 end(text?: string | Uint8Array): void;
3031 write(text: string | Uint8Array): void;
3132}
3233
......@@ -54,6 +55,13 @@ interface Builtins {
5455 ...args: unknown[]
5556 ): string;
5657 };
58 "stream": {
59 Writable: {
60 prototype: {
61 write: (value: string) => boolean;
62 };
63 };
64 };
5765}
5866/**
5967 * Subset of Node.js binding types
lib/progress.test.ts+8-8
......@@ -4,13 +4,13 @@ test.skip("trivial end-to-end example", () => {
44 const screen = new testing.MockScreen(); // create a mock terminal
55 const root = new progress.Root(screen); // sync with mock timers
66 progress.attachToScreen(root, screen); // attach `root` to the `screen`
7 assert.equal(screen.waitCalls.length, 0); // attaching does not render
7 assert.equal(screen.timers.entries.length, 0); // attaching does not render
88
99 const a = root.start("hello");
1010 const b = root.start("cats");
1111 const _ = b.start("meow");
1212
13 assert.equal(screen.waitCalls.length, 1);
13 assert.equal(screen.timers.entries.length, 1);
1414
1515 screen.expectFrame(1, { merged: "" }); // debounce
1616 // TODO: why the full resets?
......@@ -43,8 +43,8 @@ test.skip("trivial end-to-end example", () => {
4343 });
4444});
4545
46describe("encodeEventStream", async (t) => {
47 await test("simple", async () => {
46describe("encodeEventStream", (t) => {
47 test("simple", async () => {
4848 const root = new progress.Root(); // sync with mock timers
4949
5050 const a = root.start("hello");
......@@ -77,14 +77,14 @@ describe("encodeEventStream", async (t) => {
7777});
7878
7979// ## value formatters
80test("slashValueFormatter", ({ mock }) => {
81 const fn = mock.fn((arg: number) => `<${arg}>`);
80test("slashValueFormatter", () => {
81 const fn = vi.fn((arg: number) => `<${arg}>`);
8282
8383 const fmt = progress.slashValueFormatter(fn);
8484 assert.equal(fmt(250, 0), "<250>");
85 assert.equal(fn.mock.callCount(), 1);
85 assert.equal(fn.mock.calls.length, 1);
8686 assert.equal(fmt(250, 450), "<250>/<450>");
87 assert.equal(fn.mock.callCount(), 3);
87 assert.equal(fn.mock.calls.length, 3);
8888});
8989test("bytesValueFormatter", () => {
9090 assert.equal(progress.bytesValueFormatter(250, 0), "250B");
lib/progress.ts+6-5
......@@ -753,9 +753,9 @@ export function formatUnicodeBar(progress: number, width: number): string {
753753 */
754754export function attachToScreen(
755755 root: Root,
756 { writeLine, startWidget }: Pick<
756 { write, startWidget }: Pick<
757757 log.HeadlessWidgetHost,
758 "writeLine" | "startWidget"
758 "write" | "startWidget"
759759 >,
760760): ts.Dispose {
761761 const stack = new DisposableStack();
......@@ -774,7 +774,7 @@ export function attachToScreen(
774774 stack.use(
775775 root.on(
776776 "node-detached-log",
777 (msg) => writeLine(log.formatMessage(msg, true)),
777 (msg) => write(log.formatMessage(msg, true)),
778778 ),
779779 );
780780 stack.use(root.on("node-end", (node) => {
......@@ -785,8 +785,8 @@ export function attachToScreen(
785785 if (logs.length > 0) {
786786 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;
787787 const header = `[${count} from ${title}]`;
788 writeLine(ansi.style(ansi.fgBrightBlack, header));
789 writeLine(logs.map((msg) => log.formatMessage(msg, true)).join("\n"));
788 write(ansi.style(ansi.fgBrightBlack, header) + "\n");
789 write(logs.map((msg) => log.formatMessage(msg, true)).join(""));
790790 }
791791 }));
792792
......@@ -1440,6 +1440,7 @@ function writeStreamEvent(
14401440 if (msg.custom !== undefined) {
14411441 w.stringWithLength(JSON.stringify(msg.custom));
14421442 }
1443 // TODO: serialize newline attribute
14431444 }
14441445 }
14451446 if (childrenSet !== undefined) {
lib/testing.ts+76-61
......@@ -111,7 +111,7 @@ export class SyncPromise<T> implements Promise<T> {
111111
112112export class FakeTimers {
113113 time = 0;
114 timers: Array<{
114 entries: Array<{
115115 duration: number;
116116 resolve: () => void;
117117 src: stack.Frame[];
......@@ -125,7 +125,8 @@ export class FakeTimers {
125125 const src = stack.capture(2);
126126 return async.makeCancelable(
127127 new SyncPromise((resolve) => {
128 this.timers.push({
128 ASSERT(this.entries.length === 0);
129 this.entries.push({
129130 duration: ms - this.waitTime,
130131 resolve,
131132 src,
......@@ -133,7 +134,8 @@ export class FakeTimers {
133134 this.waitTime = ms;
134135 }),
135136 () => {
136 throw new Error("TODO");
137 ASSERT(this.entries.length === 1);
138 this.entries.length = 0;
137139 },
138140 );
139141 };
......@@ -153,95 +155,105 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
153155 columns = 80;
154156 rows = 33;
155157
156 time = 0;
157
158158 stdout: string = "";
159159 stderr: string = "";
160160 out: string = "";
161161 writeCalls = 0;
162162
163 waitCalls: Array<{
164 duration: number;
165 resolve: () => void;
166 src: stack.Frame[];
167 }> = [];
168 waitTime = 0;
163 timers = new FakeTimers();
169164
170 writeLine: log.HeadlessWidgetHost["writeLine"];
165 write: log.HeadlessWidgetHost["write"];
171166 getDrawLock: log.HeadlessWidgetHost["getDrawLock"];
172167 startWidget: log.HeadlessWidgetHost["startWidget"];
173 cancel: log.HeadlessWidgetHost["cancel"];
174168 delay: log.HeadlessWidgetHost["delay"];
175169 now: log.HeadlessWidgetHost["now"];
176170
171 hasTerminalLock: null | "locked" | "temporary-unlock" = null;
172
177173 static sync(text: string[]): string {
178174 return ansi.syncStart + text.join("") + ansi.syncEnd;
179175 }
180176
181 constructor() {
177 constructor({ temporaryUnlocking }: { temporaryUnlocking?: boolean } = {}) {
182178 const host = log.headlessWidgetHost({
183 writeInteractive: (text) => {
184 this.stderr += text;
185 this.out += text;
186 this.writeCalls += 1;
187 },
188 writeOutput: (text) => {
189 this.stdout += text;
190 this.out += text;
191 this.writeCalls += 1;
192 },
193 now: () => {
194 return this.time;
195 },
196 delay: (ms) => {
197 const src = stack.capture(2);
198 return async.makeCancelable(
199 new SyncPromise((resolve) => {
200 this.waitCalls.push({
201 duration: ms - this.waitTime,
202 resolve,
203 src,
204 });
205 this.waitTime = ms;
206 }),
207 () => {
208 throw new Error("TODO");
179 lockTerminal: () => {
180 ASSERT(!this.hasTerminalLock);
181 this.hasTerminalLock = "locked";
182 return {
183 writeInteractive: (text) => {
184 this.stderr += text;
185 this.out += text;
186 this.writeCalls += 1;
209187 },
210 );
211 },
212 getSize: () => {
213 return this;
188 writeOutput: (text) => {
189 this.stdout += text;
190 this.out += text;
191 this.writeCalls += 1;
192 },
193 getSize: () => {
194 return this;
195 },
196 temporaryUnlock: temporaryUnlocking
197 ? () => {
198 ASSERT(this.hasTerminalLock === "locked");
199 this.hasTerminalLock = "temporary-unlock";
200 return () => {
201 this.hasTerminalLock = "locked";
202 };
203 }
204 : undefined,
205 close: () => {
206 ASSERT(this.hasTerminalLock === "locked");
207 this.hasTerminalLock = null;
208 },
209 };
214210 },
211 now: this.timers.now,
212 delay: this.timers.delay,
215213 });
216 this.writeLine = host.writeLine;
214 this.write = host.write;
217215 this.getDrawLock = host.getDrawLock;
218216 this.startWidget = host.startWidget;
219 this.cancel = host.cancel;
220217 this.delay = host.delay;
221218 this.now = host.now;
222219 }
223220
224 expectFrame(ms: number, { stdout, stderr, merged: out }: {
221 expectNone() {
222 ASSERT(this.timers.entries.length === 0);
223 }
224
225 expectWithoutConsume(ms: number) {
226 ASSERT(UNWRAP(this.timers.entries[0]).duration === 0);
227 }
228
229 expectFrame(ms: number | null, { stdout, stderr, merged: out }: {
225230 stdout?: string;
226231 stderr?: string;
227232 merged?: string;
228233 }) {
229 const wait = UNWRAP(this.waitCalls.shift(), "no call to MockScreen.delay");
230 ASSERT(
231 ms === wait.duration,
232 `expected ${ms}ms to pass, got ${wait.duration}, from:\n${
233 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")
234 }`,
235 );
234 if (ms != null) {
235 const wait = UNWRAP(
236 this.timers.entries.shift(),
237 "no call to MockScreen.delay",
238 );
239 ASSERT(
240 ms === wait.duration,
241 `expected ${ms}ms to pass, got ${wait.duration}, from:\n${
242 wait.src.map((frame) => stack.formatFrame(frame, true)).join("\n")
243 }`,
244 );
236245
237 this.time += wait.duration;
238 this.waitTime = 0;
239 wait.resolve();
246 this.timers.time += wait.duration;
247 this.timers.waitTime = 0;
248 wait.resolve();
249 } else {
250 ASSERT(this.timers.entries.length === 0);
251 }
240252
241253 ASSERT(
242254 out == null || this.out === out,
243255 () =>
244 `interweved out does not match\n` +
256 `merged out does not match\n` +
245257 `expected: ${ansi.debugAnsi(out ?? "")}\n` +
246258 `actual: ${ansi.debugAnsi(this.out)}\n`,
247259 );
......@@ -265,9 +277,12 @@ export class MockScreen implements Disposable, log.HeadlessWidgetHost {
265277 }
266278
267279 [Symbol.dispose]() {
268 ASSERT(this.waitCalls.length === 0, "there is a pending write!");
269 ASSERT(!this.stdout, "unread standard out" + this.stdout);
270 ASSERT(!this.stderr, "unread interactive out" + this.stderr);
280 this.cancel();
281 }
282 cancel() {
283 ASSERT(this.timers.entries.length === 0, "there is a pending write!");
284 ASSERT(!this.stdout, "unread standard out: " + this.stdout);
285 ASSERT(!this.stderr, "unread interactive out: " + this.stderr);
271286 }
272287}
273288