@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.
@@ -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"]}
@@ -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;AACtD,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AAE1G,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 } from \"@gtkx/utils\";\nimport { readHeadlessOptions, resolveHeadlessOptions, startHeadlessDisplay } from \"./headless-display.ts\";\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"]}
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",
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.3",
63
- "@gtkx/utils": "2.0.0-beta.3",
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
+ };
@@ -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
- capturedStderr: string[],
401
+ captured: CapturedStderr,
422
402
  ): string =>
423
403
  `[gtkx] the headless compositor exited (code ${String(code)}, signal ${signal ?? "null"}); ` +
424
- `every Wayland client in this worker has been severed.\n${capturedStderr.join("")}`;
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, capturedStderr: string[]): (() => void) => {
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, capturedStderr));
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 = mkdtempSync(join(tmpdir(), "gtkx-xdg-"));
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.chunks);
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