@gtkx/vitest 2.0.0-beta.3 → 2.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/headless-config.d.ts +7 -0
- package/dist/headless-config.d.ts.map +1 -0
- package/dist/headless-config.js +44 -0
- package/dist/headless-config.js.map +1 -0
- package/dist/headless-display.d.ts.map +1 -1
- package/dist/headless-display.js +26 -33
- package/dist/headless-display.js.map +1 -1
- package/dist/headless.d.ts +1 -0
- package/dist/headless.d.ts.map +1 -1
- package/dist/headless.js +1 -0
- package/dist/headless.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/reap-headless-displays.d.ts +10 -0
- package/dist/reap-headless-displays.d.ts.map +1 -0
- package/dist/reap-headless-displays.js +211 -0
- package/dist/reap-headless-displays.js.map +1 -0
- package/dist/worker-preload.js +2 -1
- package/dist/worker-preload.js.map +1 -1
- package/package.json +3 -3
- package/src/headless-config.ts +61 -0
- package/src/headless-display.ts +34 -38
- package/src/headless.ts +6 -0
- package/src/index.ts +8 -0
- package/src/reap-headless-displays.ts +309 -0
- package/src/worker-preload.ts +3 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { cleanupDirectoryIdentity, info, removeCleanupDirectory, } from "@gtkx/utils";
|
|
2
|
+
import { constants, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { createBusConfig, createHeadlessRuntimeMarker, HEADLESS_RUNTIME_MARKER, isSwayConfig, } from "./headless-config.js";
|
|
6
|
+
const RUNTIME_DIRECTORY_PATTERN = /^gtkx-xdg-[A-Za-z0-9]{6}$/;
|
|
7
|
+
const STOPPED_PROCESS_STATES = new Set(["Z", "X", "x"]);
|
|
8
|
+
const CONFIG_SIZE_LIMIT = 2048;
|
|
9
|
+
const MINIMUM_STALE_AGE_MS = 5000;
|
|
10
|
+
const RUNTIME_ROOT = tmpdir();
|
|
11
|
+
const currentUserId = () => {
|
|
12
|
+
const getuid = process.getuid;
|
|
13
|
+
return getuid === undefined ? undefined : getuid();
|
|
14
|
+
};
|
|
15
|
+
const isUserOwned = (path, userId) => {
|
|
16
|
+
try {
|
|
17
|
+
return lstatSync(path).uid === userId;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
const isPrivateRuntimeDirectory = (runtimeDir, userId) => {
|
|
24
|
+
try {
|
|
25
|
+
const stat = lstatSync(runtimeDir);
|
|
26
|
+
return stat.isDirectory() &&
|
|
27
|
+
stat.uid === userId &&
|
|
28
|
+
(stat.mode & 0o777) === 0o700 &&
|
|
29
|
+
Date.now() - stat.mtimeMs >= MINIMUM_STALE_AGE_MS &&
|
|
30
|
+
RUNTIME_DIRECTORY_PATTERN.test(basename(runtimeDir)) &&
|
|
31
|
+
dirname(runtimeDir) === RUNTIME_ROOT;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const readOwnedFile = (path, runtimeDir, userId, requiredMode) => {
|
|
38
|
+
try {
|
|
39
|
+
if (dirname(path) !== runtimeDir) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
const contents = readFileSync(path, {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
flag: constants.O_RDONLY | constants.O_NOFOLLOW,
|
|
45
|
+
});
|
|
46
|
+
const stat = lstatSync(path);
|
|
47
|
+
return stat.isFile() &&
|
|
48
|
+
stat.uid === userId &&
|
|
49
|
+
stat.size < CONFIG_SIZE_LIMIT &&
|
|
50
|
+
(requiredMode === undefined || (stat.mode & 0o777) === requiredMode)
|
|
51
|
+
? contents
|
|
52
|
+
: undefined;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const hasOnlyBusConfig = (runtimeDir) => {
|
|
59
|
+
try {
|
|
60
|
+
const entries = readdirSync(runtimeDir);
|
|
61
|
+
return entries.length === 1 && entries[0] === "session.conf";
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
const hasGeneratedRuntimeFiles = (runtimeDir, userId) => {
|
|
68
|
+
const bus = readOwnedFile(join(runtimeDir, "session.conf"), runtimeDir, userId);
|
|
69
|
+
if (bus !== createBusConfig(join(runtimeDir, "bus"))) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const sway = readOwnedFile(join(runtimeDir, "sway.conf"), runtimeDir, userId);
|
|
73
|
+
const marker = readOwnedFile(join(runtimeDir, HEADLESS_RUNTIME_MARKER), runtimeDir, userId, 0o600);
|
|
74
|
+
return marker === createHeadlessRuntimeMarker(runtimeDir) ||
|
|
75
|
+
(sway !== undefined && isSwayConfig(sway)) ||
|
|
76
|
+
hasOnlyBusConfig(runtimeDir);
|
|
77
|
+
};
|
|
78
|
+
const readProcessArguments = (pid) => {
|
|
79
|
+
try {
|
|
80
|
+
const stat = readFileSync(`/proc/${String(pid)}/stat`, "utf8");
|
|
81
|
+
const state = stat.slice(stat.lastIndexOf(") ") + 2).split(" ", 1)[0];
|
|
82
|
+
if (state === undefined || STOPPED_PROCESS_STATES.has(state)) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
return readFileSync(`/proc/${String(pid)}/cmdline`)
|
|
86
|
+
.toString()
|
|
87
|
+
.split("\0")
|
|
88
|
+
.filter((argument) => argument.length > 0);
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
const runtimeFromArgument = (argument) => {
|
|
95
|
+
const path = argument.startsWith("--config-file=") ? argument.slice("--config-file=".length) : argument;
|
|
96
|
+
const name = basename(path);
|
|
97
|
+
if (name !== "sway.conf" && name !== "session.conf") {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
const runtimeDir = dirname(path);
|
|
101
|
+
return RUNTIME_DIRECTORY_PATTERN.test(basename(runtimeDir)) && dirname(runtimeDir) === RUNTIME_ROOT
|
|
102
|
+
? runtimeDir
|
|
103
|
+
: undefined;
|
|
104
|
+
};
|
|
105
|
+
const runtimeFromEnvironment = (pid) => {
|
|
106
|
+
try {
|
|
107
|
+
const prefix = "XDG_RUNTIME_DIR=";
|
|
108
|
+
const entry = readFileSync(`/proc/${String(pid)}/environ`, "utf8")
|
|
109
|
+
.split("\0")
|
|
110
|
+
.find((value) => value.startsWith(prefix));
|
|
111
|
+
const runtimeDir = entry?.slice(prefix.length);
|
|
112
|
+
return runtimeDir !== undefined &&
|
|
113
|
+
RUNTIME_DIRECTORY_PATTERN.test(basename(runtimeDir)) &&
|
|
114
|
+
dirname(runtimeDir) === RUNTIME_ROOT
|
|
115
|
+
? runtimeDir
|
|
116
|
+
: undefined;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const isOwnedProcessId = (pid, userId) => Number.isSafeInteger(pid) && pid > 1 && isUserOwned(`/proc/${String(pid)}`, userId);
|
|
123
|
+
const addRuntimeArguments = (live, processArgs) => {
|
|
124
|
+
for (const argument of processArgs) {
|
|
125
|
+
const runtimeDir = runtimeFromArgument(argument);
|
|
126
|
+
if (runtimeDir !== undefined) {
|
|
127
|
+
live.add(runtimeDir);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const addProcessRuntimes = (live, pid) => {
|
|
132
|
+
const processArgs = readProcessArguments(pid);
|
|
133
|
+
if (processArgs === undefined) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
addRuntimeArguments(live, processArgs);
|
|
137
|
+
const runtimeDir = runtimeFromEnvironment(pid);
|
|
138
|
+
if (runtimeDir !== undefined) {
|
|
139
|
+
live.add(runtimeDir);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const findLiveRuntimeDirectories = (userId) => {
|
|
143
|
+
const live = new Set();
|
|
144
|
+
const processEntries = readdirSync("/proc");
|
|
145
|
+
for (const entry of processEntries) {
|
|
146
|
+
const pid = Number(entry);
|
|
147
|
+
if (!isOwnedProcessId(pid, userId)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
addProcessRuntimes(live, pid);
|
|
151
|
+
}
|
|
152
|
+
return live;
|
|
153
|
+
};
|
|
154
|
+
const classifyRuntimeDirectory = (name, userId, live) => {
|
|
155
|
+
const runtimeDir = join(RUNTIME_ROOT, name);
|
|
156
|
+
if (live.has(runtimeDir) ||
|
|
157
|
+
!isPrivateRuntimeDirectory(runtimeDir, userId) ||
|
|
158
|
+
!hasGeneratedRuntimeFiles(runtimeDir, userId)) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
const cleanupDirectory = cleanupDirectoryIdentity(runtimeDir);
|
|
162
|
+
return cleanupDirectory === undefined ? [] : [{ runtimeDir, cleanupDirectory }];
|
|
163
|
+
};
|
|
164
|
+
const findStaleHeadlessDisplays = () => {
|
|
165
|
+
const userId = currentUserId();
|
|
166
|
+
if (userId === undefined) {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
const live = findLiveRuntimeDirectories(userId);
|
|
170
|
+
return readdirSync(RUNTIME_ROOT, { withFileTypes: true })
|
|
171
|
+
.filter((entry) => entry.isDirectory() && RUNTIME_DIRECTORY_PATTERN.test(entry.name))
|
|
172
|
+
.flatMap((entry) => classifyRuntimeDirectory(entry.name, userId, live));
|
|
173
|
+
};
|
|
174
|
+
const isSameCleanupDirectory = (left, right) => left?.device === right.device &&
|
|
175
|
+
left.inode === right.inode &&
|
|
176
|
+
left.userId === right.userId;
|
|
177
|
+
const isReapable = (candidate, userId, live) => !live.has(candidate.runtimeDir) &&
|
|
178
|
+
isSameCleanupDirectory(cleanupDirectoryIdentity(candidate.runtimeDir), candidate.cleanupDirectory) &&
|
|
179
|
+
isPrivateRuntimeDirectory(candidate.runtimeDir, userId) &&
|
|
180
|
+
hasGeneratedRuntimeFiles(candidate.runtimeDir, userId);
|
|
181
|
+
const didReapCandidate = (candidate, userId, live) => {
|
|
182
|
+
if (!isReapable(candidate, userId, live)) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
removeCleanupDirectory(candidate.cleanupDirectory);
|
|
186
|
+
return cleanupDirectoryIdentity(candidate.runtimeDir) === undefined;
|
|
187
|
+
};
|
|
188
|
+
const reapStaleHeadlessDisplays = (candidates = findStaleHeadlessDisplays()) => {
|
|
189
|
+
const userId = currentUserId();
|
|
190
|
+
if (userId === undefined) {
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const live = findLiveRuntimeDirectories(userId);
|
|
194
|
+
const removed = [];
|
|
195
|
+
for (const candidate of candidates) {
|
|
196
|
+
if (didReapCandidate(candidate, userId, live)) {
|
|
197
|
+
removed.push(candidate.runtimeDir);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return removed;
|
|
201
|
+
};
|
|
202
|
+
const reapStaleHeadlessDisplaysAtStartup = () => {
|
|
203
|
+
const removed = reapStaleHeadlessDisplays();
|
|
204
|
+
if (removed.length === 0) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const noun = removed.length === 1 ? "directory" : "directories";
|
|
208
|
+
info(`removed stale headless runtime ${noun}: ${removed.join(", ")}`);
|
|
209
|
+
};
|
|
210
|
+
export { findStaleHeadlessDisplays, reapStaleHeadlessDisplays, reapStaleHeadlessDisplaysAtStartup, };
|
|
211
|
+
//# sourceMappingURL=reap-headless-displays.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"reap-headless-displays.js","sourceRoot":"","sources":["../src/reap-headless-displays.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n type CleanupDirectoryIdentity,\n cleanupDirectoryIdentity,\n info,\n removeCleanupDirectory,\n} from \"@gtkx/utils\";\nimport { constants, lstatSync, readdirSync, readFileSync } from \"node:fs\";\nimport { tmpdir } from \"node:os\";\nimport { basename, dirname, join } from \"node:path\";\nimport {\n createBusConfig,\n createHeadlessRuntimeMarker,\n HEADLESS_RUNTIME_MARKER,\n isSwayConfig,\n} from \"./headless-config.ts\";\n\ntype StaleHeadlessDisplay = {\n runtimeDir: string;\n cleanupDirectory: CleanupDirectoryIdentity;\n};\n\nconst RUNTIME_DIRECTORY_PATTERN = /^gtkx-xdg-[A-Za-z0-9]{6}$/;\nconst STOPPED_PROCESS_STATES: Set<string> = new Set([\"Z\", \"X\", \"x\"]);\nconst CONFIG_SIZE_LIMIT = 2048;\nconst MINIMUM_STALE_AGE_MS = 5000;\nconst RUNTIME_ROOT = tmpdir();\n\nconst currentUserId = (): number | undefined => {\n const getuid = process.getuid;\n\n return getuid === undefined ? undefined : getuid();\n};\n\nconst isUserOwned = (path: string, userId: number): boolean => {\n try {\n return lstatSync(path).uid === userId;\n } catch {\n return false;\n }\n};\n\nconst isPrivateRuntimeDirectory = (runtimeDir: string, userId: number): boolean => {\n try {\n const stat = lstatSync(runtimeDir);\n\n return stat.isDirectory() &&\n stat.uid === userId &&\n (stat.mode & 0o777) === 0o700 &&\n Date.now() - stat.mtimeMs >= MINIMUM_STALE_AGE_MS &&\n RUNTIME_DIRECTORY_PATTERN.test(basename(runtimeDir)) &&\n dirname(runtimeDir) === RUNTIME_ROOT;\n } catch {\n return false;\n }\n};\n\nconst readOwnedFile = (\n path: string,\n runtimeDir: string,\n userId: number,\n requiredMode?: number,\n): string | undefined => {\n try {\n if (dirname(path) !== runtimeDir) {\n return undefined;\n }\n\n const contents = readFileSync(path, {\n encoding: \"utf8\",\n flag: constants.O_RDONLY | constants.O_NOFOLLOW,\n });\n const stat = lstatSync(path);\n\n return stat.isFile() &&\n stat.uid === userId &&\n stat.size < CONFIG_SIZE_LIMIT &&\n (requiredMode === undefined || (stat.mode & 0o777) === requiredMode)\n ? contents\n : undefined;\n } catch {\n return undefined;\n }\n};\n\nconst hasOnlyBusConfig = (runtimeDir: string): boolean => {\n try {\n const entries = readdirSync(runtimeDir);\n\n return entries.length === 1 && entries[0] === \"session.conf\";\n } catch {\n return false;\n }\n};\n\nconst hasGeneratedRuntimeFiles = (runtimeDir: string, userId: number): boolean => {\n const bus = readOwnedFile(join(runtimeDir, \"session.conf\"), runtimeDir, userId);\n\n if (bus !== createBusConfig(join(runtimeDir, \"bus\"))) {\n return false;\n }\n\n const sway = readOwnedFile(join(runtimeDir, \"sway.conf\"), runtimeDir, userId);\n const marker = readOwnedFile(join(runtimeDir, HEADLESS_RUNTIME_MARKER), runtimeDir, userId, 0o600);\n\n return marker === createHeadlessRuntimeMarker(runtimeDir) ||\n (sway !== undefined && isSwayConfig(sway)) ||\n hasOnlyBusConfig(runtimeDir);\n};\n\nconst readProcessArguments = (pid: number): string[] | undefined => {\n try {\n const stat = readFileSync(`/proc/${String(pid)}/stat`, \"utf8\");\n const state = stat.slice(stat.lastIndexOf(\") \") + 2).split(\" \", 1)[0];\n\n if (state === undefined || STOPPED_PROCESS_STATES.has(state)) {\n return undefined;\n }\n\n return readFileSync(`/proc/${String(pid)}/cmdline`)\n .toString()\n .split(\"\\0\")\n .filter((argument) => argument.length > 0);\n } catch {\n return undefined;\n }\n};\n\nconst runtimeFromArgument = (argument: string): string | undefined => {\n const path = argument.startsWith(\"--config-file=\") ? argument.slice(\"--config-file=\".length) : argument;\n const name = basename(path);\n\n if (name !== \"sway.conf\" && name !== \"session.conf\") {\n return undefined;\n }\n\n const runtimeDir = dirname(path);\n\n return RUNTIME_DIRECTORY_PATTERN.test(basename(runtimeDir)) && dirname(runtimeDir) === RUNTIME_ROOT\n ? runtimeDir\n : undefined;\n};\n\nconst runtimeFromEnvironment = (pid: number): string | undefined => {\n try {\n const prefix = \"XDG_RUNTIME_DIR=\";\n const entry = readFileSync(`/proc/${String(pid)}/environ`, \"utf8\")\n .split(\"\\0\")\n .find((value) => value.startsWith(prefix));\n const runtimeDir = entry?.slice(prefix.length);\n\n return runtimeDir !== undefined &&\n RUNTIME_DIRECTORY_PATTERN.test(basename(runtimeDir)) &&\n dirname(runtimeDir) === RUNTIME_ROOT\n ? runtimeDir\n : undefined;\n } catch {\n return undefined;\n }\n};\n\nconst isOwnedProcessId = (pid: number, userId: number): boolean =>\n Number.isSafeInteger(pid) && pid > 1 && isUserOwned(`/proc/${String(pid)}`, userId);\n\nconst addRuntimeArguments = (live: Set<string>, processArgs: string[]): void => {\n for (const argument of processArgs) {\n const runtimeDir = runtimeFromArgument(argument);\n\n if (runtimeDir !== undefined) {\n live.add(runtimeDir);\n }\n }\n};\n\nconst addProcessRuntimes = (live: Set<string>, pid: number): void => {\n const processArgs = readProcessArguments(pid);\n\n if (processArgs === undefined) {\n return;\n }\n\n addRuntimeArguments(live, processArgs);\n const runtimeDir = runtimeFromEnvironment(pid);\n\n if (runtimeDir !== undefined) {\n live.add(runtimeDir);\n }\n};\n\nconst findLiveRuntimeDirectories = (userId: number): Set<string> => {\n const live: Set<string> = new Set();\n const processEntries = readdirSync(\"/proc\");\n\n for (const entry of processEntries) {\n const pid = Number(entry);\n\n if (!isOwnedProcessId(pid, userId)) {\n continue;\n }\n\n addProcessRuntimes(live, pid);\n }\n\n return live;\n};\n\nconst classifyRuntimeDirectory = (\n name: string,\n userId: number,\n live: ReadonlySet<string>,\n): StaleHeadlessDisplay[] => {\n const runtimeDir = join(RUNTIME_ROOT, name);\n\n if (\n live.has(runtimeDir) ||\n !isPrivateRuntimeDirectory(runtimeDir, userId) ||\n !hasGeneratedRuntimeFiles(runtimeDir, userId)\n ) {\n return [];\n }\n\n const cleanupDirectory = cleanupDirectoryIdentity(runtimeDir);\n\n return cleanupDirectory === undefined ? [] : [{ runtimeDir, cleanupDirectory }];\n};\n\nconst findStaleHeadlessDisplays = (): StaleHeadlessDisplay[] => {\n const userId = currentUserId();\n\n if (userId === undefined) {\n return [];\n }\n\n const live = findLiveRuntimeDirectories(userId);\n\n return readdirSync(RUNTIME_ROOT, { withFileTypes: true })\n .filter((entry) => entry.isDirectory() && RUNTIME_DIRECTORY_PATTERN.test(entry.name))\n .flatMap((entry) => classifyRuntimeDirectory(entry.name, userId, live));\n};\n\nconst isSameCleanupDirectory = (\n left: CleanupDirectoryIdentity | undefined,\n right: CleanupDirectoryIdentity,\n): boolean =>\n left?.device === right.device &&\n left.inode === right.inode &&\n left.userId === right.userId;\n\nconst isReapable = (\n candidate: StaleHeadlessDisplay,\n userId: number,\n live: ReadonlySet<string>,\n): boolean =>\n !live.has(candidate.runtimeDir) &&\n isSameCleanupDirectory(cleanupDirectoryIdentity(candidate.runtimeDir), candidate.cleanupDirectory) &&\n isPrivateRuntimeDirectory(candidate.runtimeDir, userId) &&\n hasGeneratedRuntimeFiles(candidate.runtimeDir, userId);\n\nconst didReapCandidate = (\n candidate: StaleHeadlessDisplay,\n userId: number,\n live: ReadonlySet<string>,\n): boolean => {\n if (!isReapable(candidate, userId, live)) {\n return false;\n }\n\n removeCleanupDirectory(candidate.cleanupDirectory);\n\n return cleanupDirectoryIdentity(candidate.runtimeDir) === undefined;\n};\n\nconst reapStaleHeadlessDisplays = (\n candidates: readonly StaleHeadlessDisplay[] = findStaleHeadlessDisplays(),\n): string[] => {\n const userId = currentUserId();\n\n if (userId === undefined) {\n return [];\n }\n\n const live = findLiveRuntimeDirectories(userId);\n const removed: string[] = [];\n\n for (const candidate of candidates) {\n if (didReapCandidate(candidate, userId, live)) {\n removed.push(candidate.runtimeDir);\n }\n }\n\n return removed;\n};\n\nconst reapStaleHeadlessDisplaysAtStartup = (): void => {\n const removed = reapStaleHeadlessDisplays();\n\n if (removed.length === 0) {\n return;\n }\n\n const noun = removed.length === 1 ? \"directory\" : \"directories\";\n info(`removed stale headless runtime ${noun}: ${removed.join(\", \")}`);\n};\n\nexport {\n findStaleHeadlessDisplays,\n reapStaleHeadlessDisplays,\n reapStaleHeadlessDisplaysAtStartup,\n type StaleHeadlessDisplay,\n};\n"]}
|
package/dist/worker-preload.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { installGracefulShutdown } from "@gtkx/utils";
|
|
1
|
+
import { installGracefulShutdown, watchParentProcess } from "@gtkx/utils";
|
|
2
2
|
import { readHeadlessOptions, resolveHeadlessOptions, startHeadlessDisplay } from "./headless-display.js";
|
|
3
|
+
watchParentProcess();
|
|
3
4
|
const options = readHeadlessOptions(new URL(import.meta.url).searchParams);
|
|
4
5
|
const teardown = await startHeadlessDisplay(resolveHeadlessOptions(options));
|
|
5
6
|
process.on("exit", teardown);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"worker-preload.js","sourceRoot":"","sources":["../src/worker-preload.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"worker-preload.js","sourceRoot":"","sources":["../src/worker-preload.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,uBAAuB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAE1G,kBAAkB,EAAE,CAAC;AAErB,MAAM,OAAO,GAAG,mBAAmB,CAAC,IAAI,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,YAAY,CAAC,CAAC;AAC3E,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,sBAAsB,CAAC,OAAO,CAAC,CAAC,CAAC;AAE7E,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;AAC7B,uBAAuB,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAC","sourcesContent":["import { installGracefulShutdown, watchParentProcess } from \"@gtkx/utils\";\nimport { readHeadlessOptions, resolveHeadlessOptions, startHeadlessDisplay } from \"./headless-display.ts\";\n\nwatchParentProcess();\n\nconst options = readHeadlessOptions(new URL(import.meta.url).searchParams);\nconst teardown = await startHeadlessDisplay(resolveHeadlessOptions(options));\n\nprocess.on(\"exit\", teardown);\ninstallGracefulShutdown({ onSignal: teardown });\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gtkx/vitest",
|
|
3
|
-
"version": "2.0.0-beta.
|
|
3
|
+
"version": "2.0.0-beta.5",
|
|
4
4
|
"description": "Vitest plugin for GTK apps with headless Wayland isolation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"gtkx",
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"node": ">=26.7.0"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@gtkx/config": "2.0.0-beta.
|
|
63
|
-
"@gtkx/utils": "2.0.0-beta.
|
|
62
|
+
"@gtkx/config": "2.0.0-beta.5",
|
|
63
|
+
"@gtkx/utils": "2.0.0-beta.5",
|
|
64
64
|
"@homebridge/dbus-native": "^0.7.9"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const BUS_CONFIG_DOCTYPE =
|
|
2
|
+
'<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" ' +
|
|
3
|
+
'"https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">';
|
|
4
|
+
|
|
5
|
+
const HEADLESS_RUNTIME_MARKER = ".gtkx-headless-runtime";
|
|
6
|
+
|
|
7
|
+
const SWAY_CONFIG_LINES = [
|
|
8
|
+
/^xwayland disable$/,
|
|
9
|
+
/^default_border none$/,
|
|
10
|
+
/^default_floating_border none$/,
|
|
11
|
+
/^output HEADLESS-1 resolution [1-9]\d*x[1-9]\d*$/,
|
|
12
|
+
/^output HEADLESS-1 bg #000000 solid_color$/,
|
|
13
|
+
/^for_window \[app_id="\.\*"\] floating enable, border none$/,
|
|
14
|
+
/^for_window \[title="\.\*"\] floating enable, border none$/,
|
|
15
|
+
/^$/,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const createSwayConfig = (width: string, height: string): string =>
|
|
19
|
+
[
|
|
20
|
+
"xwayland disable",
|
|
21
|
+
"default_border none",
|
|
22
|
+
"default_floating_border none",
|
|
23
|
+
`output HEADLESS-1 resolution ${width}x${height}`,
|
|
24
|
+
"output HEADLESS-1 bg #000000 solid_color",
|
|
25
|
+
'for_window [app_id=".*"] floating enable, border none',
|
|
26
|
+
'for_window [title=".*"] floating enable, border none',
|
|
27
|
+
"",
|
|
28
|
+
].join("\n");
|
|
29
|
+
|
|
30
|
+
const createBusConfig = (busSocketPath: string): string =>
|
|
31
|
+
[
|
|
32
|
+
BUS_CONFIG_DOCTYPE,
|
|
33
|
+
"<busconfig>",
|
|
34
|
+
" <type>session</type>",
|
|
35
|
+
` <listen>unix:path=${busSocketPath}</listen>`,
|
|
36
|
+
" <auth>EXTERNAL</auth>",
|
|
37
|
+
' <policy context="default">',
|
|
38
|
+
' <allow send_destination="*" eavesdrop="true"/>',
|
|
39
|
+
' <allow eavesdrop="true"/>',
|
|
40
|
+
' <allow own="*"/>',
|
|
41
|
+
" </policy>",
|
|
42
|
+
"</busconfig>",
|
|
43
|
+
].join("\n");
|
|
44
|
+
|
|
45
|
+
const createHeadlessRuntimeMarker = (runtimeDir: string): string =>
|
|
46
|
+
["gtkx-headless-runtime-v1", `runtime=${runtimeDir}`, ""].join("\n");
|
|
47
|
+
|
|
48
|
+
const isSwayConfig = (value: string): boolean => {
|
|
49
|
+
const lines = value.split("\n");
|
|
50
|
+
|
|
51
|
+
return lines.length === SWAY_CONFIG_LINES.length &&
|
|
52
|
+
SWAY_CONFIG_LINES.every((pattern, index) => pattern.test(lines[index] ?? "invalid"));
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
createBusConfig,
|
|
57
|
+
createHeadlessRuntimeMarker,
|
|
58
|
+
createSwayConfig,
|
|
59
|
+
HEADLESS_RUNTIME_MARKER,
|
|
60
|
+
isSwayConfig,
|
|
61
|
+
};
|
package/src/headless-display.ts
CHANGED
|
@@ -4,6 +4,12 @@ import { chmodSync, closeSync, existsSync, mkdtempSync, openSync, rmSync, writeF
|
|
|
4
4
|
import { Socket } from "node:net";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
createBusConfig,
|
|
9
|
+
createHeadlessRuntimeMarker,
|
|
10
|
+
createSwayConfig,
|
|
11
|
+
HEADLESS_RUNTIME_MARKER,
|
|
12
|
+
} from "./headless-config.ts";
|
|
7
13
|
import { startNotificationService } from "./notification-service.ts";
|
|
8
14
|
import { startVirtualSeat } from "./virtual-seat.ts";
|
|
9
15
|
|
|
@@ -59,6 +65,7 @@ type DisplaySockets = {
|
|
|
59
65
|
|
|
60
66
|
type CapturedStderr = {
|
|
61
67
|
chunks: string[];
|
|
68
|
+
logPath: string;
|
|
62
69
|
stop: () => void;
|
|
63
70
|
};
|
|
64
71
|
|
|
@@ -72,10 +79,6 @@ const DEFAULT_HEADLESS_SIZE = "1024x768";
|
|
|
72
79
|
const DEFAULT_HEADLESS_COMPOSITOR: CompositorId = "sway";
|
|
73
80
|
const HEADLESS_SIZE_PATTERN = /^[1-9]\d*x[1-9]\d*$/;
|
|
74
81
|
|
|
75
|
-
const BUS_CONFIG_DOCTYPE =
|
|
76
|
-
'<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN" ' +
|
|
77
|
-
'"https://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">';
|
|
78
|
-
|
|
79
82
|
const hasWestonFakeSeat = createWestonFakeSeatProbe();
|
|
80
83
|
|
|
81
84
|
const compositorRegistry: Record<CompositorId, CompositorDescriptor> = {
|
|
@@ -94,16 +97,7 @@ const compositorRegistry: Record<CompositorId, CompositorDescriptor> = {
|
|
|
94
97
|
|
|
95
98
|
writeFileSync(
|
|
96
99
|
configPath,
|
|
97
|
-
|
|
98
|
-
"xwayland disable",
|
|
99
|
-
"default_border none",
|
|
100
|
-
"default_floating_border none",
|
|
101
|
-
`output HEADLESS-1 resolution ${width}x${height}`,
|
|
102
|
-
"output HEADLESS-1 bg #000000 solid_color",
|
|
103
|
-
'for_window [app_id=".*"] floating enable, border none',
|
|
104
|
-
'for_window [title=".*"] floating enable, border none',
|
|
105
|
-
"",
|
|
106
|
-
].join("\n"),
|
|
100
|
+
createSwayConfig(width, height),
|
|
107
101
|
);
|
|
108
102
|
|
|
109
103
|
return spawnWithParentDeathSupervisor("sway", ["-c", configPath], {
|
|
@@ -228,22 +222,7 @@ const attachVirtualSeat = (compositor: SpawnedCompositor, socketPath: string): P
|
|
|
228
222
|
compositor.requiresVirtualSeat ? startVirtualSeat(socketPath) : Promise.resolve(noVirtualSeat);
|
|
229
223
|
|
|
230
224
|
const writeBusConfig = (busConfigPath: string, busSocketPath: string): void => {
|
|
231
|
-
writeFileSync(
|
|
232
|
-
busConfigPath,
|
|
233
|
-
[
|
|
234
|
-
BUS_CONFIG_DOCTYPE,
|
|
235
|
-
"<busconfig>",
|
|
236
|
-
" <type>session</type>",
|
|
237
|
-
` <listen>unix:path=${busSocketPath}</listen>`,
|
|
238
|
-
" <auth>EXTERNAL</auth>",
|
|
239
|
-
' <policy context="default">',
|
|
240
|
-
' <allow send_destination="*" eavesdrop="true"/>',
|
|
241
|
-
' <allow eavesdrop="true"/>',
|
|
242
|
-
' <allow own="*"/>',
|
|
243
|
-
" </policy>",
|
|
244
|
-
"</busconfig>",
|
|
245
|
-
].join("\n"),
|
|
246
|
-
);
|
|
225
|
+
writeFileSync(busConfigPath, createBusConfig(busSocketPath));
|
|
247
226
|
};
|
|
248
227
|
|
|
249
228
|
const exitedMessage = (label: string, path: string, code: number | null, signal: NodeJS.Signals | null): string =>
|
|
@@ -383,7 +362,7 @@ const captureCompositorStderr = (child: ChildProcess, logPath: string): Captured
|
|
|
383
362
|
const stderr = child.stderr;
|
|
384
363
|
|
|
385
364
|
if (stderr === null) {
|
|
386
|
-
return { chunks: captured, stop: noVirtualSeat };
|
|
365
|
+
return { chunks: captured, logPath, stop: noVirtualSeat };
|
|
387
366
|
}
|
|
388
367
|
|
|
389
368
|
stderr.setEncoding("utf8");
|
|
@@ -404,6 +383,7 @@ const captureCompositorStderr = (child: ChildProcess, logPath: string): Captured
|
|
|
404
383
|
|
|
405
384
|
return {
|
|
406
385
|
chunks: captured,
|
|
386
|
+
logPath,
|
|
407
387
|
stop: () => {
|
|
408
388
|
stderr.removeListener("data", onData);
|
|
409
389
|
|
|
@@ -418,14 +398,15 @@ const captureCompositorStderr = (child: ChildProcess, logPath: string): Captured
|
|
|
418
398
|
const compositorExitMessage = (
|
|
419
399
|
code: number | null,
|
|
420
400
|
signal: NodeJS.Signals | null,
|
|
421
|
-
|
|
401
|
+
captured: CapturedStderr,
|
|
422
402
|
): string =>
|
|
423
403
|
`[gtkx] the headless compositor exited (code ${String(code)}, signal ${signal ?? "null"}); ` +
|
|
424
|
-
|
|
404
|
+
"every Wayland client in this worker has been severed. " +
|
|
405
|
+
`Its stderr log is kept at ${captured.logPath} until this worker exits.\n${captured.chunks.join("")}`;
|
|
425
406
|
|
|
426
|
-
const watchCompositorExit = (child: ChildProcess,
|
|
407
|
+
const watchCompositorExit = (child: ChildProcess, captured: CapturedStderr): (() => void) => {
|
|
427
408
|
const report = (code: number | null, signal: NodeJS.Signals | null): void => {
|
|
428
|
-
process.stderr.write(compositorExitMessage(code, signal,
|
|
409
|
+
process.stderr.write(compositorExitMessage(code, signal, captured));
|
|
429
410
|
};
|
|
430
411
|
|
|
431
412
|
child.on("exit", report);
|
|
@@ -457,10 +438,25 @@ const makeTeardown = (stops: (() => void)[]): (() => void) => {
|
|
|
457
438
|
};
|
|
458
439
|
};
|
|
459
440
|
|
|
441
|
+
const createHeadlessRuntimeDirectory = (): string => {
|
|
442
|
+
const runtimeDir = mkdtempSync(join(tmpdir(), "gtkx-xdg-"));
|
|
443
|
+
|
|
444
|
+
try {
|
|
445
|
+
chmodSync(runtimeDir, 0o700);
|
|
446
|
+
const markerPath = join(runtimeDir, HEADLESS_RUNTIME_MARKER);
|
|
447
|
+
writeFileSync(markerPath, createHeadlessRuntimeMarker(runtimeDir), { flag: "wx", mode: 0o600 });
|
|
448
|
+
chmodSync(markerPath, 0o600);
|
|
449
|
+
|
|
450
|
+
return runtimeDir;
|
|
451
|
+
} catch (error) {
|
|
452
|
+
rmSync(runtimeDir, { recursive: true, force: true });
|
|
453
|
+
throw error;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
460
457
|
const startHeadlessDisplay = async (options: HeadlessOptions): Promise<() => void> => {
|
|
461
458
|
const env: EnvSnapshot = {};
|
|
462
|
-
const runtimeDir =
|
|
463
|
-
chmodSync(runtimeDir, 0o700);
|
|
459
|
+
const runtimeDir = createHeadlessRuntimeDirectory();
|
|
464
460
|
const spawned: ChildProcess[] = [];
|
|
465
461
|
|
|
466
462
|
const removeRuntime = (): void => {
|
|
@@ -494,7 +490,7 @@ const startHeadlessDisplay = async (options: HeadlessOptions): Promise<() => voi
|
|
|
494
490
|
const stopVirtualSeat = await attachVirtualSeat(compositor, compositorSocketPath);
|
|
495
491
|
const stopNotifications = await startNotificationService(`unix:path=${busSocketPath}`);
|
|
496
492
|
const capturedStderr = captureCompositorStderr(compositor.child, join(runtimeDir, "compositor.stderr.log"));
|
|
497
|
-
const stopExitWatch = watchCompositorExit(compositor.child, capturedStderr
|
|
493
|
+
const stopExitWatch = watchCompositorExit(compositor.child, capturedStderr);
|
|
498
494
|
|
|
499
495
|
return makeTeardown([
|
|
500
496
|
stopExitWatch,
|
package/src/headless.ts
CHANGED
|
@@ -6,3 +6,9 @@ export {
|
|
|
6
6
|
type CompositorId,
|
|
7
7
|
type HeadlessOptions,
|
|
8
8
|
} from "./headless-display.js";
|
|
9
|
+
export {
|
|
10
|
+
findStaleHeadlessDisplays,
|
|
11
|
+
reapStaleHeadlessDisplays,
|
|
12
|
+
reapStaleHeadlessDisplaysAtStartup,
|
|
13
|
+
type StaleHeadlessDisplay,
|
|
14
|
+
} from "./reap-headless-displays.js";
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { existsSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
7
|
import { type HeadlessOptions, STATIC_HEADLESS_ENV } from "./headless-display.ts";
|
|
8
|
+
import { reapStaleHeadlessDisplaysAtStartup } from "./reap-headless-displays.ts";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Options accepted by the GTKX Vitest plugin. Every headless display
|
|
@@ -37,11 +38,18 @@ const headlessPreloadSpecifier = (options: Partial<HeadlessOptions>): string =>
|
|
|
37
38
|
* Wayland display. It configures the forks pool, injects the worker preload and
|
|
38
39
|
* setup files, and sets the environment needed for headless GTK4 rendering.
|
|
39
40
|
*
|
|
41
|
+
* Each worker's compositor, session bus, and private runtime directory are torn
|
|
42
|
+
* down when the worker exits, and a guard process tears them down as well when
|
|
43
|
+
* the worker or the Vitest process that launched it is killed with `SIGKILL`.
|
|
44
|
+
* Creating the plugin reaps stale `gtkx-xdg-*` runtime directories left behind
|
|
45
|
+
* by earlier runs, which `gtkx cleanup` also does on demand.
|
|
46
|
+
*
|
|
40
47
|
* @param options Headless display settings (size, compositor) forwarded to each worker.
|
|
41
48
|
* @returns A Vitest config plugin.
|
|
42
49
|
*/
|
|
43
50
|
const gtkx = (options: PluginOptions = {}): Plugin => {
|
|
44
51
|
assertSupportedNodeVersion();
|
|
52
|
+
reapStaleHeadlessDisplaysAtStartup();
|
|
45
53
|
const { configFile, ...headlessOptions } = options;
|
|
46
54
|
const loadConfig = createConfigLoader({ configFile });
|
|
47
55
|
|