Repository navigation
Expand file tree
/
Copy pathtb-ide.mjs
More file actions
1848 lines (1789 loc) · 81.9 KB
/
Copy pathtb-ide.mjs
File metadata and controls
1848 lines (1789 loc) · 81.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Starting a twinBASIC IDE, reaching it over CDP, reading its build state,
// building the project it has open, and ending it. The mechanics
// scripts/tbbuild.mjs and scripts/tbrun.mjs share, and the ones the add-in
// harness in WIP.HelpAddin.md (Stage 1) is built on. Reading its DEBUG CONSOLE
// is tb-ide-console.mjs, and asking about its add-ins tb-ide-addins.mjs.
//
// Each function here was once inline in one of those two scripts, and the
// comments that explain it moved with it. See WIP.Harness.md, "Compiling a
// twinBASIC project without the IDE in front of you", for the history.
import { execFile, execFileSync, spawn } from "node:child_process";
import { mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { secondsText, timeoutMs } from "./harness-settings.mjs";
import { connectBroker } from "./tb-broker.mjs";
import { attach } from "./tb-cdp.mjs";
import { click, pagePicture } from "./tb-click.mjs";
import { checkDefaults, installDefaultsHook } from "./tb-defaults.mjs";
import { removeTree, removeTreeAsync } from "./tb-ide-copy.mjs";
import { consoleMark, keepClears, keptClears, linesSince, readConsole } from "./tb-ide-console.mjs";
import { unsupportedIde } from "./tb-install.mjs";
import { timing, timingProject, timingsOn } from "./tb-timings.mjs";
import { watchCompiles } from "./tb-wire.mjs";
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// The IDE echoes projectFilePath back with whichever separators it was given.
export const normPath = (p) => p.split("\\").join("/").toLowerCase();
/**
* The environment variable that tells an add-in it is under test. While it is
* set, an add-in does nothing outside the IDE: it prints each such action to
* the DEBUG CONSOLE instead, as `open <url>` for a URL it would have opened in
* a browser (WIP.HelpAddin.md, Stage 1 item 6). A browser started from an IDE
* on a private desktop would open where nobody can see it and outlive the run.
*
* launchIde sets it to "1" for every IDE the harness starts, tbbuild's and
* tbrun's included: any of them can load an add-in the user has installed,
* and none has anybody watching it. Measured (P10): it reaches the
* compiler, twinBASIC_win32_noDEP.exe, which the IDE starts as its own child,
* and every add-in that compiler loads, including after the compiler restarts.
*/
export const ADDIN_TEST_ENV = "TB_ADDIN_TEST";
/**
* The two command-line flags every IDE the harness starts is given (BETA 1007
* and later; tb-install's MIN_BUILD is the oldest build the tools run).
*
* `--no-save-ide-settings` keeps the IDE from writing its settings to the
* registry: its options (the theme included), panel layouts, keyboard groups,
* the build target it remembers for a project, the licence key and agreements.
* `--no-update-recent-files` keeps it from adding a project to the recent list.
* They leave some writes unguarded. lib/tb-registry.mjs puts back two of them:
* ProjectState and the `.twinproj` association the executable registers. The
* other two happen only when the page is operated: the Window key, written when
* an option that lives there changes, and removals from the recent list, made
* from the start page and some error boxes. The page guards in shoot_docs,
* svgshot/probe and test/ide block those calls.
*
* Never add `--reset-ide-settings` here or anywhere: it writes empty options to
* the real registry even after `--no-save-ide-settings`.
*
* A flag may stand anywhere on the command line; the one positional argument is
* the project. An unknown `--` flag is ignored, and a second positional gives a
* modal "Bad command line syntax" box nobody can answer on a private desktop.
*/
export const IDE_FLAGS = ["--no-update-recent-files", "--no-save-ide-settings"];
/**
* Whether the IDE goes on the user's desktop or on a private one.
*
* Hidden by default so an unattended run cannot take the keyboard, but the
* private desktop also hides a wedged IDE from the person debugging it -- so
* the choice is a switch, and TBBUILD_SHOW makes it a session-wide one rather
* than something to remember on every invocation.
*/
export function wantShow({ show = false, hide = false } = {}) {
return (
show ||
(!hide && !!process.env.TBBUILD_SHOW && !["0", "false", "no", ""].includes(process.env.TBBUILD_SHOW.toLowerCase()))
);
}
/**
* Kill a process and everything it started.
*
* An IDE showing a modal dialog ignores a normal close, and the launcher is
* not the process that holds the compiler, so kill the tree and force it.
* A tree kill misses a process started while it runs -- a compiler the IDE is
* restarting after a crash, for one -- and the job tb-launch.ps1 puts the IDE
* in is what ends those.
*/
export function killTree(pid) {
try {
execFileSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
} catch {
/* already gone */
}
}
// ------------------------------------------------------------ slots and desktops
// Every private desktop, and the number each run names its folders and ids by, come from
// tbbroker (WIP.Broker.md): one broker for every tool on the machine, which makes each desktop
// once and hands it from one launch to the next. A desktop made for every launch is never freed
// by the desktop compositor, and within a day or two of tests every window on the screen lags.
// The process shares one connection to it, and a lease taken on that connection ends when it is
// released, or handed to a process and that process ends, or when this process ends.
//
// TB_BROKER=off does without it, for the one launch that cannot use it: building the broker
// (scripts/tbbroker.mjs install). Each launch then makes a desktop of its own, as before.
const NO_BROKER = process.env.TB_BROKER === "off";
let brokerConnection = null;
let noBrokerSerial = 0;
function sharedBroker() {
if (!brokerConnection) {
brokerConnection = connectBroker({ client: path.basename(process.argv[1] ?? "node") }).then(
(b) => {
// The broker went away: the next lease reconnects, and starts one if need be.
b.onLost = () => {
brokerConnection = null;
};
return b;
},
(e) => {
brokerConnection = null;
throw e;
},
);
}
return brokerConnection;
}
/**
* A slot from the broker: a private desktop that is this run's alone while it holds it, a
* number to name its folders, ids and work by (unique among the slots held on the machine),
* and a port when `port` asks for one. It is held until `release()`, or until `handTo(pid)`
* has handed it to a process and that process has ended, or until this process ends.
*
* @param {object} [o]
* @param {string} [o.tag] what it is for, shown by `tbbroker status`
* @param {boolean} [o.port] a port as well, chosen before it is bound (an IDE's DevTools port
* is not: launchIde lets Windows pick it)
* @returns {Promise<{slot: number, desktop: string, port: number, handTo: (pid: number) => Promise<void>, release: () => Promise<void>}>}
* Waits while every slot is taken.
*/
export async function leaseSlot({ tag = "", port = false } = {}) {
if (NO_BROKER) {
if (port) throw new Error("a port needs tbbroker, and TB_BROKER=off");
const n = ++noBrokerSerial;
// Far above any slot the broker hands out (32 by default), and within laneProjectId's 24 bits.
const slot = 0x100000 + ((process.pid * 64 + n) % 0xe00000);
const none = async () => {};
return { slot, desktop: `tbnobroker-${process.pid}-${n}`, port: 0, handTo: none, release: none };
}
const b = await sharedBroker();
const r = await b.lease({ tag, port, wait: true });
let released = false;
const release = async () => {
if (released) return;
released = true;
await b.release(r.lease).catch(() => {
/* the connection is gone, and the lease with it */
});
};
return {
slot: r.slot,
desktop: r.desktop,
port: r.port,
release,
async handTo(pid) {
try {
await b.hold(r.lease, pid);
} catch {
// the process has ended already, so nothing is left on the desktop to keep it for
}
await release();
},
};
}
/**
* Start the IDE on one project, with a DevTools port open.
*
* The project goes on the command line, so the IDE opens it as its own first
* act and no startup state has to be raced.
*
* Two things about that are easy to get wrong. `parseCommandLine()` in
* ide/main2.js splits the command line on " " and pushes every token, so a
* TRAILING SPACE becomes an empty second argument and the IDE refuses the
* launch with "Bad command line syntax." PowerShell's Start-Process appends
* exactly that space; spawn with an argv array does not. And loading the
* project afterwards through root.loadProject works but lets the IDE's
* no-project startup run first, which flashes the splash and the New/Open
* Project dialog on screen before the load replaces them.
*
* By default the IDE goes on a private Windows desktop, where it cannot take
* the keyboard away from whatever you are doing. It has to be a desktop and
* not a window style: the IDE calls HostForceFocus() from its own
* window.onload, so `start /min` was tried and does not help. It compiles
* there exactly as it does on screen. `show` opts out, for watching it work
* or for diagnosing the harness itself.
*
* @param {object} o
* @param {string} o.exe twinBASIC.exe
* @param {string} [o.project] the .twinproj to open; resolved here. Without one
* the IDE starts as from its icon: the splash, then
* the New / Open Project dialog, every menu in its
* no-project state
* @param {object} [o.slot] a slot from leaseSlot that the caller holds: the IDE goes on its
* desktop, and its temp folder and WebView2 profile are named by
* its number. Without one the IDE gets a slot of its own, held for
* as long as the IDE runs (with `keep`, past this process's end)
* @param {number} [o.port] a DevTools port to open; by default the IDE's WebView2 binds one
* Windows picks, and the result's `port` says which
* @param {boolean} [o.show] on the user's desktop instead of a private one
* @param {boolean} [o.keep] the IDE is to outlive this Node process
*
* **The DevTools port is not chosen in advance.** WebView2 is given
* `--remote-debugging-port=0` (after the IDE's own argument; the last one wins), binds a
* port Windows picks, and writes it to `EBWebView\DevToolsActivePort` in its profile
* folder, which is this IDE's alone: so IDEs started together never meet on a port, and
* nor does an IDE meet a port some other program was given. A fixed port collided often,
* since on the dev box Windows hands out any port above 1024 to whatever asks for one.
*
* A `port` given by the caller is refused when something already listens on it.
* The harness attaches to whatever page answers on its port, so if another IDE
* already holds it, that IDE is the one the harness would read and operate. An
* IDE ended a moment ago holds its port a little longer than it lives: 13 and
* 16 ms after shutdownIde, and once two seconds. So the port gets ten seconds to
* come free before the launch is refused. The refusal names the process
* listening on the port, or, when none is, the error the bind got: Windows
* refuses a port it has reserved with EACCES.
*
* @param {object} [o.env] extra environment for the IDE. ADDIN_TEST_ENV is
* set to "1" unless this names it; a value of
* undefined leaves a variable out altogether
* @param {string} [o.browserArgs] more WebView2 browser arguments, after the
* DevTools ones, e.g. "--force-device-scale-factor=1"
* to lay the page out at 100% whatever the display's
* scaling
* @param {string[]} [o.flags] the IDE's own command-line flags (default IDE_FLAGS).
* Only a test of the very thing a flag guards leaves
* one out, and it puts back what it writes: the recent
* list's test (test/ide/recent-projects-copies.test.mjs)
* @param {boolean} [o.userSettings] the IDE keeps the user's own settings (default false:
* the IDE is answered "no saved value" for its options, panel
* layouts and keyboard groups, in the page only, so it runs with
* the defaults; tb-defaults.mjs). Only a tool that is the user's own
* IDE in all but its project passes true: try-help-addin
* @returns {Promise<{pid: number, launcher: import("node:child_process").ChildProcess | null, folders: string[], port: number | null, slot: number, desktop: string | null, hook: object | null}>}
* `pid` is the IDE's own. `folders` are its temp folder and WebView2 profile,
* which shutdownIde removes. `port` is its DevTools port, for attachIde, or
* null when the IDE never opened one within a minute. `desktop` is the private
* desktop it is on (null with `show`). Throws when a given port is taken, or
* when the launch fails.
*/
export async function launchIde({
exe,
project,
slot = null,
port = null,
show = false,
keep = false,
env = {},
browserArgs = "",
flags = IDE_FLAGS,
userSettings = false,
}) {
// Before anything starts: an IDE older than the flags would write the user's settings.
const tooOld = unsupportedIde(exe);
if (tooOld) throw new Error(tooOld);
if (port != null) await waitForPort(port);
const t0 = Date.now();
const own = slot ? null : await leaseSlot({ tag: `IDE ${path.basename(project || exe)}` });
const s = slot ?? own;
try {
const leased = Date.now();
const started = await startIde({ exe, project, s, own, port, show, keep, env, browserArgs, flags, userSettings });
timing("launch", {
project: project ? path.resolve(project) : null,
leaseMs: leased - t0,
ms: Date.now() - leased,
});
return started;
} catch (e) {
await own?.release();
throw e;
}
}
// launchIde once the slot is settled; `own` is the slot when it is launchIde's own to hand on.
async function startIde({ exe, project, s, own, port, show, keep, env, browserArgs, flags, userSettings }) {
const exeWin = exe.split("/").join("\\");
const target = project ? path.resolve(project).split("/").join("\\") : "";
// Each IDE has a temp folder of its own, emptied at each launch in its slot
// and removed when the IDE ends (shutdownIde).
const ideTemp = path.join(process.env.TEMP, `tbbuild-tmp-${s.slot}`);
// The WebView2 profile, 28 MB once the IDE has run. Emptied here as well, for
// what a run that was killed left behind, and removed when the IDE ends
// (shutdownIde), so that a run leaves neither folder.
const ideProfile = path.join(process.env.TEMP, `tbbuild-wv2-${s.slot}`);
for (const dir of [ideTemp, ideProfile]) {
try {
rmSync(dir, { recursive: true, force: true });
} catch {
// a file still held open: the IDE gets the folder as it is
}
}
mkdirSync(ideTemp, { recursive: true });
// What shutdownIde removes once the IDE is gone: only these two, which this
// function has just made or emptied, never a folder a caller names.
const folders = [ideTemp, ideProfile];
const started = Date.now();
// Node leaves out of a child's environment any variable whose value is
// undefined, so `env` can remove one as well as set it.
const fullEnv = {
...process.env,
TEMP: ideTemp,
TMP: ideTemp,
WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS: [
`--remote-debugging-port=${port ?? 0}`,
"--remote-allow-origins=*",
browserArgs,
]
.filter(Boolean)
.join(" "),
WEBVIEW2_USER_DATA_FOLDER: ideProfile,
[ADDIN_TEST_ENV]: "1",
...env,
};
const found = (pid) => (port != null ? Promise.resolve(port) : devToolsPort(ideProfile, pid, started));
// Started before the IDE is, since main.htm commits a fraction of a second after its port file
// is written; attachIde checks the hook ran, by the port. The hook's connection lives as long
// as this process, so a kept IDE (`keep`) is hooked only until this process ends.
const hook = userSettings ? null : installDefaultsHook({ profile: ideProfile, since: started });
const hooked = async (pid, launcher) => {
const p = await found(pid);
if (hook && p) defaultsHooks.set(p, hook);
return { pid, launcher, folders, port: p, slot: s.slot, desktop: show ? null : s.desktop, hook };
};
if (show) {
const child = spawn(exeWin, [...flags, ...(target ? [target] : [])], {
detached: true,
stdio: "ignore",
env: fullEnv,
});
// A spawn that fails is reported by an 'error' event, not a throw, and an
// 'error' nothing listens for ends this process with exit 1, which tbbuild
// and tbrun define as compile errors. 'spawn' says the IDE started.
await new Promise((resolve, reject) => {
child.once("spawn", resolve);
child.once("error", (e) => reject(new Error(`could not start the IDE: ${e.message}`)));
});
child.unref();
// The slot names its folders; the IDE outlives nothing here, so it keeps the slot.
await own?.handTo(child.pid);
return hooked(child.pid, null);
}
// lib/tb-launch.ps1 does the Win32 calls Node cannot -- CreateDesktop,
// CreateProcess with STARTUPINFO.lpDesktop, and a job object that ends every
// process the IDE starts once the launcher's handle to it closes. It is
// passed as -EncodedCommand rather than run as a file, so no execution
// policy is involved; its inputs arrive as environment variables, so there is
// no argument quoting to get wrong.
//
// How long the launcher lives is how long the IDE lives, and Node decides
// that. Node puts the children it spawns into a kill-on-close job of its
// own, so when this process ends -- finished, crashed, or stopped with
// Ctrl+C -- the launcher ends too, the launcher's job closes, and the IDE
// goes with it. That is what a run wants: before the job, a run that died
// left its IDEs open on desktops nobody could see.
//
// A kept IDE is the exception, and under `keep` the launcher makes no job:
// the IDE then escapes Node's job the way every process the launcher
// starts does, and lives until somebody closes it. Both of the other ways
// were measured and fail. With the job, a --keep IDE was gone before
// anything could attach to it, because the launcher died with tbbuild and
// took the job with it. With the launcher detached from Node instead,
// PowerShell exited at once, without a pid and without a word on stderr.
//
// The slot goes with whichever lives as long as the IDE: the launcher, or a
// kept IDE itself.
let launched;
try {
launched = await launchOnDesktop({
exe: exeWin,
arg: target,
args: flags,
desktop: s.desktop,
job: !keep,
env: fullEnv,
});
} catch (e) {
hook?.stop();
// No IDE started, so nothing holds the folders and nothing else would remove them.
for (const dir of folders) rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
throw new Error(
`could not start the IDE on a private desktop:\n${e.message}\n(--show runs it on your own desktop instead)`,
);
}
const { pid, launcher } = launched;
await own?.handTo(keep ? pid : launcher.pid);
return hooked(pid, launcher);
}
// The hook of each IDE launchIde started with the defaults, by its DevTools port: attachIde checks
// the IDEs it is told the port of, so no caller says whether to.
const defaultsHooks = new Map();
// The DevTools port the IDE's WebView2 opened: the first line of DevToolsActivePort in its
// profile, written once its browser process has bound the port. Only a file written since the
// launch counts, in case a folder could not be emptied. Null when the IDE ends, or a minute
// passes, without one.
async function devToolsPort(profile, pid, since) {
const file = path.join(profile, "EBWebView", "DevToolsActivePort");
const until = Date.now() + 60 * 1000;
while (Date.now() < until) {
try {
if (statSync(file).mtimeMs >= since - 2000) {
const n = Number(readFileSync(file, "utf8").split(/\r?\n/)[0]);
if (Number.isInteger(n) && n > 0) return n;
}
} catch {
// not written yet
}
try {
process.kill(pid, 0);
} catch {
return null;
}
await sleep(100);
}
return null;
}
// A DevTools port the caller chose: wait up to ten seconds for it to come free (see launchIde).
async function waitForPort(port) {
const waitFrom = Date.now();
for (;;) {
const code = await portTaken(port);
if (!code) break;
if (Date.now() - waitFrom > 10 * 1000) {
const holder = portListeners(port);
throw new Error(
holder
? `DevTools port ${port} is in use: ${holder} listens on it. Another IDE has it, perhaps ` +
"one another session started, and this one could be mistaken for it. Pass a different --port."
: `DevTools port ${port} cannot be bound (${code}), though nothing listens on it: Windows ` +
"may have reserved it (netsh int ipv4 show excludedportrange protocol=tcp). Pass a " +
"different --port.",
);
}
await sleep(100);
}
}
/**
* One argument as it goes into a Windows command line, quoted the way the C
* runtime reads it back (and Node writes it): an argument with a space, a tab or
* a quote, or an empty one, is wrapped in quotes; the backslashes before a quote,
* and at the end of a wrapped argument, are doubled. `C:\out\` stays `C:\out\`,
* and `C:\my out\` becomes `"C:\my out\\"`, which is what keeps the closing quote
* from being escaped.
*/
export function quoteArg(a) {
if (a !== "" && !/[\s"]/.test(a)) return a;
return `"${a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"`;
}
// What powershell runs for launchOnDesktop: the script in TBBUILD_SCRIPT, taken
// out of the environment first.
const BOOTSTRAP = "$s = $env:TBBUILD_SCRIPT; Remove-Item Env:TBBUILD_SCRIPT; & ([scriptblock]::Create($s))";
/**
* Start a program on a private desktop, inside a kill-on-close job, through
* lib/tb-launch.ps1. launchIde starts the IDE this way, and tbrun's --exe the
* probe's exe, so that neither can show a window on the user's desktop.
*
* @param {object} o
* @param {string} o.exe the program, a Windows path
* @param {string} [o.arg] its one argument; none when empty
* @param {string} [o.desktop] the private desktop, from a slot the caller holds (leaseSlot).
* Without one the program gets a slot of its own, held for as
* long as the program runs; the result's `desktop` names it, for
* desktopWindows and captureWindow. Never a name made up here:
* every desktop comes from the broker
* @param {string} [o.tag] what a slot of its own is for, shown by `tbbroker status`
* @param {boolean} [o.job] false for no job, for a program that is to outlive
* this Node process
* @param {object} o.env its whole environment
* @param {string[]} [o.args] more arguments, after `arg`, for a program that takes
* several; quoted as the C runtime reads them
* @param {string} [o.cwd] its working folder; the program's own folder when left out
* @param {string} [o.stdout] a file path: the program's standard output goes there
* @param {string} [o.stderr] the same for standard error. With either, the program's
* standard input is the null device, and an output left
* out goes to the null device
* @param {"close"} [o.dialogs] look for dialog boxes the program (or anything in its
* job) opens on the desktop, record each, and close it
* by pressing OK, so that the program can go on
* @returns {Promise<{pid: number, launcher: import("node:child_process").ChildProcess, desktop: string, exited: Promise<number | null>, finished: Promise<{code: number | null, dialogs: {title: string, text: string}[]}>}>}
* `exited` settles with the program's exit code when it ends, or null when
* the launcher ended without one -- killed, or failed. `finished` settles at
* the same time with that code and the dialogs recorded, in the order they
* opened (empty without `dialogs`). Throws, with the launcher's error, when
* the program did not start.
*/
export async function launchOnDesktop({ exe, desktop, tag, job = true, ...rest }) {
const own = desktop ? null : await leaseSlot({ tag: tag ?? path.basename(exe) });
let run;
try {
run = await startOnDesktop({ exe, desktop: desktop ?? own.desktop, job, ...rest });
} catch (e) {
await own?.release();
throw e;
}
// The slot goes with whichever lives as long as the program: the launcher, which holds
// its job, or the program itself when it has none.
await own?.handTo(job ? run.launcher.pid : run.pid);
return run;
}
// tb-launch.ps1 as PowerShell is given it. -EncodedCommand is UTF-16 in base64, 2.7 characters
// to each of the script's, and a command line stops at 32,767: the script outgrew that at about
// 12,000 characters (ENAMETOOLONG). So the script travels in an environment variable, which
// stops at 32,767 characters of its own, and the command is a bootstrap that takes it out of
// the environment, so that the program it launches does not inherit it, and runs it. Each line
// that is only a comment is emptied, which keeps an error's line number: the comments are a
// quarter of the file, and no line of its C# starts with `#`.
function launchScript() {
const script = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "tb-launch.ps1"), "utf8").replace(
/^[ \t]*#.*$/gm,
"",
);
if (script.length > 30000) {
throw new Error(`tb-launch.ps1 is ${script.length} characters, and an environment variable stops at 32,767`);
}
return script;
}
async function startOnDesktop({ exe, arg = "", args, desktop, job, env, cwd, stdout, stderr, dialogs }) {
const script = launchScript();
const ps = spawn(
"powershell",
["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(BOOTSTRAP, "utf16le").toString("base64")],
{
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
// An option left out removes the variable, so that one in the caller's
// environment cannot switch it on.
env: {
...env,
TBBUILD_SCRIPT: script,
TBBUILD_EXE: exe,
TBBUILD_ARG: arg,
TBBUILD_ARGS: args?.length ? args.map(quoteArg).join(" ") : undefined,
TBBUILD_DESKTOP: desktop,
TBBUILD_JOB: job ? "1" : "0",
TBBUILD_CWD: cwd || undefined,
TBBUILD_STDOUT: stdout || undefined,
TBBUILD_STDERR: stderr || undefined,
TBBUILD_DIALOGS: dialogs || undefined,
},
},
);
let err = "",
out = "";
ps.stderr.on("data", (d) => {
err += d;
});
ps.stdout.on("data", (d) => {
out += d;
});
// "close" rather than "exit": the exit line can still be in the pipe at "exit".
const ended = new Promise((res) => ps.on("close", res));
const pid = await new Promise((res) => {
ps.stdout.on("data", () => {
const m = /^\s*(\d+)\s*$/m.exec(out);
if (m) res(Number(m[1]));
});
ended.then(() => res(null));
});
if (!pid) throw new Error(err.trim());
const finished = ended.then(() => {
const m = /^exit (-?\d+)\s*$/m.exec(out);
const found = [];
for (const d of out.matchAll(/^dialog ([A-Za-z0-9+/=]+)\s*$/gm)) {
try {
const { title, text } = JSON.parse(Buffer.from(d[1], "base64").toString("utf8"));
found.push({ title: String(title ?? ""), text: String(text ?? "") });
} catch {
// a line the launcher did not finish writing
}
}
return { code: m ? Number(m[1]) : null, dialogs: found };
});
const exited = finished.then((f) => f.code);
return { pid, launcher: ps, desktop, exited, finished };
}
// Runs tb-launch.ps1 in its window mode (TBBUILD_WINDOWS) on a private desktop and returns
// the one line of JSON it prints; throws with its error.
function onDesktopWindows(desktop, vars) {
const script = launchScript();
return new Promise((resolve, reject) => {
const ps = spawn(
"powershell",
["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(BOOTSTRAP, "utf16le").toString("base64")],
{
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
env: { ...process.env, TBBUILD_SCRIPT: script, TBBUILD_DESKTOP: desktop, ...vars },
},
);
let out = "",
err = "";
ps.stdout.on("data", (d) => {
out += d;
});
ps.stderr.on("data", (d) => {
err += d;
});
ps.on("error", reject);
ps.on("close", (code) => {
if (code) {
reject(new Error(`the windows of desktop ${desktop}: ${err.trim() || `exit ${code}`}`));
return;
}
try {
resolve(JSON.parse(out.trim()));
} catch {
reject(new Error(`the windows of desktop ${desktop}: no JSON in ${JSON.stringify(out.slice(0, 200))}`));
}
});
});
}
/**
* The windows on a private desktop that launchOnDesktop or launchIde started a program on (the
* `desktop` each returns) while the program runs: every top-level window, and with `children`
* the child windows of each visible one, as tb-launch.ps1 describes them. A desktop is leased
* to one run at a time, so every window on it is the program's or a process it started.
*
* @param {string} desktop
* @param {object} [o]
* @param {boolean} [o.children]
* @returns {Promise<{hwnd: number, depth: number, class: string, title: string, pid: number, exe: string, visible: boolean, style: number, exStyle: number, owner: number, dpi: number, rect: number[]}[]>}
* `rect` is [left, top, right, bottom] in physical pixels; `depth` 1 for a child
*/
export function desktopWindows(desktop, { children = false } = {}) {
return onDesktopWindows(desktop, { TBBUILD_WINDOWS: "list", TBBUILD_CHILDREN: children ? "1" : "0" });
}
/**
* A picture of one window on a private desktop, written as a PNG file: PrintWindow, which
* draws a window whether or not its desktop is the one on screen, with PW_RENDERFULLCONTENT
* by default (`flags` 2), the only way it holds what a WebView2 draws. Its frame is as the
* system draws it and its size the window's in physical pixels, at the desktop's DPI.
*
* The picture is not a page's, so the visible-text check of shoot_docs does not cover it:
* `texts` holds the window's title and its child windows' texts, which the caller searches
* for the Windows user name. Text a window draws itself (a WebView2's page, an owner-drawn
* control) is not among them.
*
* @param {string} desktop
* @param {number} hwnd from desktopWindows
* @param {string} file the PNG file to write
* @param {object} [o]
* The picture includes the invisible resize borders of a Windows 10 frame: `frame` is the
* visible frame in it (DWMWA_EXTENDED_FRAME_BOUNDS) and `client` the client area, each
* [left, top, right, bottom] in the picture's pixels. With PW_CLIENTONLY (1) the client area
* is drawn at the picture's top left. `layered` is a layered window's [colour key as a
* COLORREF, alpha, LWA_ flags], else null: PW_RENDERFULLCONTENT draws the key colour's pixels
* black and every pixel opaque, and flags 1 draws them in the key colour. `active` says whether
* its caption is drawn as the active window's (WS_ACTIVECAPTION).
*
* @param {number} [o.flags] PrintWindow's flags (default 2)
* @returns {Promise<{width: number, height: number, dpi: number, frame: number[], client: number[], layered: number[] | null, active: boolean, texts: string[]}>}
*/
export function captureWindow(desktop, hwnd, file, { flags = 2 } = {}) {
return onDesktopWindows(desktop, {
TBBUILD_WINDOWS: "capture",
TBBUILD_HWND: String(hwnd),
TBBUILD_FLAGS: String(flags),
TBBUILD_FILE: path.resolve(file),
});
}
/**
* Posts a message to a window on a private desktop (PostMessage). A key pressed on a desktop
* that is not on screen reaches no window, so this is how a window's own menu is opened there:
* WM_SYSCOMMAND (0x112) with SC_KEYMENU (0xF100) and a letter as lParam, as Alt and the letter
* do. The menu is then a top-level window of class #32768 on the desktop.
*
* @param {string} desktop
* @param {number} hwnd from desktopWindows
* @param {number} msg
* @param {number} [wParam]
* @param {number} [lParam]
* @returns {Promise<{posted: true}>}
*/
export function postWindowMessage(desktop, hwnd, msg, wParam = 0, lParam = 0) {
return onDesktopWindows(desktop, {
TBBUILD_WINDOWS: "post",
TBBUILD_HWND: String(hwnd),
TBBUILD_MSG: String(msg),
TBBUILD_WPARAM: String(wParam),
TBBUILD_LPARAM: String(lParam),
});
}
/**
* Makes a window on a private desktop the active window there, as a click on it does: a
* program started on a desktop that is not on screen can be left with its caption drawn
* inactive. Its thread's input is attached to the caller's for SetForegroundWindow,
* SetActiveWindow and SetFocus.
*
* @param {string} desktop
* @param {number} hwnd from desktopWindows
* @returns {Promise<{active: boolean}>} whether its caption is then drawn active
*/
export function activateWindow(desktop, hwnd) {
return onDesktopWindows(desktop, { TBBUILD_WINDOWS: "activate", TBBUILD_HWND: String(hwnd) });
}
/**
* What a tool prints under --keep: the kept IDE's pid, and the commands that end
* it with every process it started (/T takes the compiler and the rest of its
* tree), in each shell's spelling; Git Bash would read /PID as a path.
*/
export function keptIdeLines(pid) {
return [
`ide-pid: ${pid}`,
"to end the IDE and every process it started:",
` cmd or PowerShell: taskkill /PID ${pid} /T /F`,
` Git Bash: taskkill //PID ${pid} //T //F`,
];
}
/**
* End an IDE started by launchIde, and the launcher holding its desktop, and
* wait until the IDE's process is gone.
*
* The wait is for whatever runs next. The IDE writes its project state to the
* registry while it runs, and taskkill only asks for the
* end of a process: tidying the registry before the process has actually gone
* could lose to one last write. It is normally over in well under a second,
* and it gives up after five.
*
* Once the process is gone its temp folder and WebView2 profile are removed
* (`ide.folders`, which launchIde made): without that every run left both under
* %TEMP%, 28 MB of profile for each IDE. A process still alive keeps
* its folders, since they are in use. A folder that cannot be removed is
* reported on stderr and left; launchIde empties it when the slot is next used.
*/
export function shutdownIde(ide) {
if (!ide) return;
killTree(ide.pid);
ide.hook?.stop();
// The launcher holds the private desktop open and the only handle to the
// IDE's job. It exits once the IDE does, but do not wait on that: killing it
// closes the job, which ends anything the tree kill missed.
try {
ide.launcher?.kill();
} catch {
/* already gone */
}
if (!waitForExit(ide.pid, 5000)) return;
for (const dir of ide.folders ?? []) {
try {
removeTree(dir);
} catch (e) {
console.error(`could not remove ${dir}: ${e.code ?? e.message}`);
}
}
}
/**
* shutdownIde without blocking the event loop, for a caller that ends an IDE
* while other work is going on in the same process. shutdownIde's `taskkill` and
* its wait each hold the process still, up to five seconds for the wait, and
* with four builds at once that is four lanes' CDP timers running out while
* their answers sit unread in a socket. The sync version stays for the paths
* that end in process.exit(), where nothing else is waiting.
*/
export async function shutdownIdeAsync(ide) {
if (!ide) return;
await new Promise((resolve) => {
execFile("taskkill", ["/PID", String(ide.pid), "/T", "/F"], { windowsHide: true }, () => resolve());
});
ide.hook?.stop();
try {
ide.launcher?.kill();
} catch {
/* already gone */
}
const until = Date.now() + 5000;
for (;;) {
try {
process.kill(ide.pid, 0);
} catch {
break;
}
if (Date.now() >= until) return;
await sleep(100);
}
// The folders go as shutdownIde's do, with its own reasons.
await Promise.all(
(ide.folders ?? []).map((dir) =>
removeTreeAsync(dir).catch((e) => {
console.error(`could not remove ${dir}: ${e.code ?? e.message}`);
}),
),
);
}
/**
* Block until a process has exited, or the time runs out.
*
* Synchronous on purpose: shutdown runs on paths that end in process.exit().
*
* @returns {boolean} true if the process is gone
*/
export function waitForExit(pid, timeoutMs) {
const until = Date.now() + timeoutMs;
const cell = new Int32Array(new SharedArrayBuffer(4));
while (Date.now() < until) {
// Signal 0 tests whether the process exists; it throws once it does not.
try {
process.kill(pid, 0);
} catch {
return true;
}
Atomics.wait(cell, 0, 0, 100);
}
return false;
}
/**
* Attach to the IDE's page once its DevTools port answers; null if it never does.
*
* The connection records and dismisses every javascript dialog the page opens,
* in `c.dialogs` as `{type, message, at}`. The IDE calls `alert()` from 37
* places in BETA 1010 (main.js and main2.js), and never `confirm()` or `prompt()`. An alert blocks the
* page until it is answered, which on a private desktop nobody can do: every
* later call on the connection would time out. An alert is accepted, since it has only the
* one button; a confirm or a prompt, which only an add-in could open, is
* cancelled. CDP reports dialogs only once `Page.enable` has been sent, and
* until this function sent it, `tbbuild`'s list of dialogs could never fill.
*
* **A dialog that opened before this attached cannot be answered.** Measured:
* `Page.enable` got no answer while it was open, the new connection was told
* of no dialog, and `Page.handleJavaScriptDialog` replied "No dialog is
* showing" while the page stayed blocked. The IDE's own candidates are its
* "IDE startup failure" alert and "Bad command line syntax.", which
* launchIde's single argument never provokes. Such a page is marked
* `c.pageBlocked`, and waitForCompile passes that on, so the failure names the
* likely cause; `--show` puts the dialog where a person can read it.
*
* `c.wire` follows the page's compiles from its own traffic, through CDP's
* Network domain (tb-wire.mjs), so that waitForCompile can tell when a compile
* has ended without waiting for the status bar to stay still. A page that did
* not answer `Page.enable` would not answer `Network.enable` either, and it
* gets none.
*/
export async function attachIde(port, { tries = 60 } = {}) {
// launchIde's port is null when the IDE never opened one.
if (!port) return null;
for (let i = 0; i < tries; i++) {
await sleep(1000);
let c;
try {
c = await attach(port);
} catch {
continue;
} // still starting
c.dialogs = [];
c.pageBlocked = false;
const hook = defaultsHooks.get(port);
c.on((m) => {
if (m.method !== "Page.javascriptDialogOpening") return;
const { type, message } = m.params;
c.dialogs.push({ type, message, at: Date.now() });
c.send("Page.handleJavaScriptDialog", { accept: type === "alert" || type === "beforeunload" }).catch(() => {
/* already answered, or the page is gone */
});
});
try {
await c.send("Page.enable");
} catch {
c.pageBlocked = true;
}
// A blocked page answers nothing, the hook's marker included.
if (hook && !c.pageBlocked) await checkDefaults(c, hook);
c.wire = null;
if (!c.pageBlocked) {
const wire = watchCompiles(c);
try {
await c.send("Network.enable");
c.wire = wire;
} catch {
/* waitForCompile falls back to the status bar alone */
}
}
return c;
}
return null;
}
// Whether something already listens on a loopback port. A DevTools server
// binds 127.0.0.1, so binding it ourselves for a moment is the test. Resolves
// to the bind's error code, or null when the port is free: a port Windows has
// reserved refuses the bind with EACCES though nothing listens on it.
export const portTaken = (port) =>
new Promise((resolve) => {
const s = net.createServer();
s.once("error", (e) => resolve(e.code ?? "an error"));
s.once("listening", () => s.close(() => resolve(null)));
s.listen(port, "127.0.0.1");
});
// The processes listening on a port launchIde was refused, for its message,
// as netstat and tasklist name them: "pid 1234 (name.exe)". Null when none
// does. When netstat itself fails, that is said instead.
function portListeners(port) {
const opts = { encoding: "utf8", windowsHide: true, timeout: 10 * 1000 };
let pids;
try {
pids = [
...new Set(
execFileSync("netstat", ["-ano", "-p", "TCP"], opts)
.split(/\r?\n/)
.map((l) => l.trim().split(/\s+/))
.filter((f) => f[3] === "LISTENING" && f[1]?.endsWith(`:${port}`))
.map((f) => f[4]),
),
];
} catch (e) {
return `a process netstat could not name (${e.code ?? e.message.split("\n")[0]})`;
}
if (!pids.length) return null;
return pids
.map((pid) => {
try {
const row = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], opts).trim();
const image = /^"([^"]+)"/.exec(row)?.[1];
return image ? `pid ${pid} (${image})` : `pid ${pid}`;
} catch {
return `pid ${pid}`;
}
})
.join(" and ");
}
// Counts and rows are read in ONE evaluate. Read separately they raced: a run
// reported two diagnostics beside a zero error count, because the background
// compile finished between the two calls.
//
// `crash` reads the DEBUG CONSOLE, and it is not an extra: the status-bar watch
// in waitForCompile MISSES a compiler crash, which is the worst failure the
// harness has, because a crashed compile reports zero of everything and exits 0.
// Measured on a 275-file project -- twice, reproducibly -- against quarters of
// the same project that reported 129, 0, 294 and 448 errors. The IDE writes
// "NATIVE EXCEPTION: ACCESS_VIOLATION" and "restarting from MEMORY" to the
// console, restarts the compiler three times, and then leaves the status at
// OPERATIONAL with the counters at zero, which is byte-identical to a clean
// build. The status does flap to UNAVAILABLE on the way, but the whole
// crash-restart cycle takes about 1.3 s and the loop samples at 1 Hz, so
// `drops` never reaches its threshold. The console is the only record that
// cannot be missed by sampling: nothing removes an entry from it.
const CRASH_JS = `(() => {
if (typeof debugConsoleContent === "undefined" || !debugConsoleContent ||
!debugConsoleContent.dataNodes) return null;
let n = 0; const files = [];
for (const entry of debugConsoleContent.dataNodes) {
if (entry.indexOf("NATIVE EXCEPTION") >= 0) n++;
const m = /ParsingFileStart, ([^<\\s]+)/.exec(entry);
if (m && files.indexOf(m[1]) < 0) files.push(m[1]);
}
return n ? { n: n, files: files } : null;
})()`;
const BUILD_STATE_JS = `JSON.stringify({
st: document.getElementById("compilerStatus")?.textContent ?? "",
e: document.getElementById("errorCount")?.textContent ?? "",
w: document.getElementById("warningCount")?.textContent ?? "",
h: document.getElementById("hintCount")?.textContent ?? "",
i: document.getElementById("infoCount")?.textContent ?? "",
p: typeof projectFilePath !== "undefined" ? projectFilePath : null,
// Safe Mode: the IDE sets the build box to nocompile itself after four
// compiler restarts in a minute, and the compiler it then starts compiles
// nothing, so the status and the counts stand still as a settled compile does.
safe: typeof buildConfigSelector !== "undefined" && !!buildConfigSelector &&
buildConfigSelector.value === "nocompile",