-
Notifications
You must be signed in to change notification settings - Fork 8
Expand file tree
/
Copy pathtb-ide-copy.mjs
More file actions
165 lines (158 loc) · 7.58 KB
/
Copy pathtb-ide-copy.mjs
File metadata and controls
165 lines (158 loc) · 7.58 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
// A private copy of a twinBASIC install, one per test lane.
//
// The compiler loads every DLL in <install>\addins\win32 or \win64 as it
// starts. A test add-in built or copied there would load into every IDE the
// user starts from that install, and two lanes testing different add-ins could
// not share the folder at all. So each lane runs its own copy of the install,
// whose addins folders hold exactly what the lane put there and nothing else --
// not even the Global Search add-in the install ships with.
//
// A copy, not hardlinks. The install is 233 files and 57 MB once the sample
// projects are left out, which copies in well under a second. Hardlinks would
// save that, at the price of sharing every file with the user's install, so
// that any write the IDE made into its own folder would land in the real one.
// None was measured -- a compile, a compiler crash and a build-and-run session
// changed no file, not even an mtime (WIP.HelpAddin.md, P11) -- but a copy
// makes the question irrelevant instead of merely answered.
//
// Left out: projects\ (the samples and New Project templates, 29 MB; a test
// opens its project on the command line and never shows that dialog) and
// addins\ (recreated, holding only what the caller asks for).
//
// The copy keeps the install's folder name, twinBASIC_IDE_BETA_<n>, so that
// tb-install's buildNumber() still reads the build off its path.
//
// Starting an IDE from a new path makes it register the .twinproj association
// to itself (see tb-registry.mjs). Whatever starts IDEs from a copy has to
// hold a startTidy/finishTidy around them, which tbbuild and tbrun already do.
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
const MARKER = ".tbharness-ide-copy";
const LEFT_OUT = new Set(["projects", "addins"]);
// A copy is created and deleted only inside the temp folder, and deleted only
// where this module left its marker: a wrong `dest` must not be able to empty
// a folder that somebody cares about.
function insideTemp(p) {
const rel = path.relative(path.resolve(tmpdir()), path.resolve(p));
return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
}
/**
* Copy an install into `dest`, with private add-in folders.
*
* @param {object} o
* @param {string} o.ide the install's twinBASIC.exe
* @param {string} o.dest a folder inside the temp folder; the copy goes in
* `<dest>\<install folder name>`. An earlier copy
* made there by this module is replaced; anything
* else there is refused.
* @param {{win32?: string[], win64?: string[]}} [o.addins] DLLs to put in the
* copy's addins folders; both are created, empty if
* not named
* @returns {string} the copy's twinBASIC.exe
*/
export function makeIdeCopy({ ide, dest, addins = {} }) {
const install = path.dirname(path.resolve(ide));
if (!insideTemp(dest)) throw new Error(`refusing to make an IDE copy outside ${tmpdir()}: "${dest}"`);
const root = path.join(path.resolve(dest), path.basename(install));
if (existsSync(root)) {
if (!existsSync(path.join(root, MARKER))) {
throw new Error(`refusing to replace "${root}": it is not an IDE copy this module made`);
}
rmSync(root, { recursive: true, force: true });
}
mkdirSync(root, { recursive: true });
for (const entry of readdirSync(install)) {
if (LEFT_OUT.has(entry.toLowerCase())) continue;
cpSync(path.join(install, entry), path.join(root, entry), { recursive: true });
}
for (const arch of ["win32", "win64"]) {
const dir = path.join(root, "addins", arch);
mkdirSync(dir, { recursive: true });
for (const dll of addins[arch] ?? []) cpSync(dll, path.join(dir, path.basename(dll)));
}
writeFileSync(path.join(root, MARKER),
`A private copy of ${install}, made by scripts/lib/tb-ide-copy.mjs. Safe to delete.\n`);
return path.join(root, "twinBASIC.exe");
}
/**
* Put an add-in DLL into a copy's addins\<arch> folder, replacing one of the
* same name, so that the copy's next IDE loads it.
*
* Refuses anything that is not a copy made by makeIdeCopy: in the real install
* a test add-in would load into every IDE the user starts. End the copy's IDEs
* first. A compiler holds every add-in it loaded: Windows refuses to overwrite
* or delete the file while it runs (WIP.HelpAddin.md, P8), and for a moment
* after -- the first overwrite after shutdownIde failed and one 25 ms later
* worked, four times out of four -- so a refused copy is retried for two
* seconds before it counts. The refusal comes back as EIO from the copy here
* and as EBUSY from a plain write.
*
* @param {string} exe the copy's twinBASIC.exe, as makeIdeCopy returned it
* @param {string} dll the add-in
* @param {"win32" | "win64"} arch
* @param {string} [name] the file name to give it there; by default its own
* @returns {string} where the DLL now is
*/
export function addAddin(exe, dll, arch, name = path.basename(dll)) {
const root = path.dirname(path.resolve(exe));
if (!insideTemp(root) || !existsSync(path.join(root, MARKER))) {
throw new Error(`refusing to add an add-in to "${root}": it is not an IDE copy this module made`);
}
if (arch !== "win32" && arch !== "win64") throw new Error(`no such add-in folder: "${arch}"`);
if (path.basename(name) !== name) throw new Error(`an add-in's file name, not a path: "${name}"`);
const dest = path.join(root, "addins", arch, name);
const cell = new Int32Array(new SharedArrayBuffer(4));
for (let tries = 1; ; tries++) {
try {
cpSync(dll, dest);
return dest;
} catch (e) {
if (!["EIO", "EBUSY", "EPERM"].includes(e.code) || tries >= 20) {
throw new Error(`could not put the add-in in "${dest}" (${e.code}) -- ` +
"is an IDE started from this copy still running?");
}
Atomics.wait(cell, 0, 0, 100);
}
}
}
/**
* Delete a copy made by makeIdeCopy. Refuses anything without its marker, and
* anything outside the temp folder. End the copy's IDEs first: a running IDE
* holds its files open.
*
* @param {string} exe the copy's twinBASIC.exe, as makeIdeCopy returned it
*/
export function removeIdeCopy(exe) {
const root = path.dirname(path.resolve(exe));
if (!insideTemp(root) || !existsSync(path.join(root, MARKER))) {
throw new Error(`refusing to delete "${root}": it is not an IDE copy this module made`);
}
removeTree(root);
}
/**
* Delete a folder and everything in it, retrying for up to `timeout`
* milliseconds while something still holds a file there.
*
* An IDE ended a moment ago can still be letting go of its files: its compiler
* held a loaded add-in some tens of milliseconds after the process had gone
* (WIP.HelpAddin.md, P8). rmSync's own maxRetries does not cover that. On
* Node 24.13 it gave up at once, in a millisecond, with EPERM on a folder
* holding a file another process had open, with maxRetries 10 and retryDelay
* 200 exactly as with neither (measured). So the retrying is done here.
* Anything still holding a file after that is a process that outlived its
* IDE, and the EPERM is the right thing to report.
*/
export function removeTree(dir, { timeout = 5000 } = {}) {
const until = Date.now() + timeout;
const cell = new Int32Array(new SharedArrayBuffer(4));
for (;;) {
try {
rmSync(dir, { recursive: true, force: true });
return;
} catch (e) {
if (!["EPERM", "EBUSY", "ENOTEMPTY", "EACCES"].includes(e.code) || Date.now() >= until) throw e;
Atomics.wait(cell, 0, 0, 100);
}
}
}