@rehearsal-labs/retest 0.2.0 → 0.3.0

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.
Files changed (218) hide show
  1. package/README.md +6 -5
  2. package/dist/api/source-location.d.ts.map +1 -1
  3. package/dist/api/source-location.js +3 -1
  4. package/dist/browser/builds.d.ts +35 -6
  5. package/dist/browser/builds.d.ts.map +1 -1
  6. package/dist/browser/builds.js +115 -16
  7. package/dist/browser/cdp/transport.d.ts.map +1 -1
  8. package/dist/browser/cdp/transport.js +4 -4
  9. package/dist/browser/chromium-process.d.ts +23 -0
  10. package/dist/browser/chromium-process.d.ts.map +1 -1
  11. package/dist/browser/chromium-process.js +78 -13
  12. package/dist/browser/electron.d.ts +1 -1
  13. package/dist/browser/electron.d.ts.map +1 -1
  14. package/dist/browser/electron.js +6 -3
  15. package/dist/browser/executables.d.ts +16 -1
  16. package/dist/browser/executables.d.ts.map +1 -1
  17. package/dist/browser/executables.js +50 -18
  18. package/dist/browser/firefox/accessible-names.d.ts +5 -4
  19. package/dist/browser/firefox/accessible-names.d.ts.map +1 -1
  20. package/dist/browser/firefox/accessible-names.js +11 -6
  21. package/dist/browser/firefox/bridge.js +3 -2
  22. package/dist/browser/firefox/executable.d.ts +14 -14
  23. package/dist/browser/firefox/executable.d.ts.map +1 -1
  24. package/dist/browser/firefox/executable.js +29 -21
  25. package/dist/browser/firefox/launch.js +1 -1
  26. package/dist/browser/firefox/navigation.d.ts +5 -2
  27. package/dist/browser/firefox/navigation.d.ts.map +1 -1
  28. package/dist/browser/firefox/navigation.js +48 -14
  29. package/dist/browser/firefox/orphans.d.ts.map +1 -1
  30. package/dist/browser/firefox/orphans.js +34 -2
  31. package/dist/browser/firefox/page.d.ts.map +1 -1
  32. package/dist/browser/firefox/page.js +113 -19
  33. package/dist/browser/firefox/process.d.ts +20 -1
  34. package/dist/browser/firefox/process.d.ts.map +1 -1
  35. package/dist/browser/firefox/process.js +124 -22
  36. package/dist/browser/firefox/profile.d.ts +5 -0
  37. package/dist/browser/firefox/profile.d.ts.map +1 -1
  38. package/dist/browser/firefox/profile.js +17 -1
  39. package/dist/browser/firefox/storage.d.ts.map +1 -1
  40. package/dist/browser/firefox/storage.js +4 -2
  41. package/dist/browser/firefox/subscriptions.d.ts +26 -0
  42. package/dist/browser/firefox/subscriptions.d.ts.map +1 -0
  43. package/dist/browser/firefox/subscriptions.js +10 -0
  44. package/dist/browser/launch.d.ts +6 -0
  45. package/dist/browser/launch.d.ts.map +1 -1
  46. package/dist/browser/launch.js +14 -1
  47. package/dist/cli/commands/doctor.js +3 -3
  48. package/dist/cli/commands/inspect.js +3 -2
  49. package/dist/cli/commands/install.d.ts.map +1 -1
  50. package/dist/cli/commands/install.js +26 -13
  51. package/dist/cli/commands/licences.d.ts +22 -0
  52. package/dist/cli/commands/licences.d.ts.map +1 -1
  53. package/dist/cli/commands/licences.js +62 -20
  54. package/dist/cli/commands/run.js +2 -2
  55. package/dist/cli/doctor/checks.js +4 -1
  56. package/dist/cli/inspect/diagnostics.js +3 -3
  57. package/dist/cli/inspect/test-timeline.js +10 -10
  58. package/dist/cli/install/doctor-rows.d.ts.map +1 -1
  59. package/dist/cli/install/doctor-rows.js +5 -4
  60. package/dist/cli/install/file-changes.d.ts +44 -0
  61. package/dist/cli/install/file-changes.d.ts.map +1 -0
  62. package/dist/cli/install/file-changes.js +81 -0
  63. package/dist/cli/install/install-archive.d.ts +3 -1
  64. package/dist/cli/install/install-archive.d.ts.map +1 -1
  65. package/dist/cli/install/install-archive.js +80 -33
  66. package/dist/cli/install/lock.d.ts.map +1 -1
  67. package/dist/cli/install/lock.js +108 -21
  68. package/dist/cli/install/media-doctor.d.ts.map +1 -1
  69. package/dist/cli/install/media-doctor.js +2 -1
  70. package/dist/cli/install/media-install.d.ts.map +1 -1
  71. package/dist/cli/install/media-install.js +38 -19
  72. package/dist/cli/install/media-pins.d.ts +7 -1
  73. package/dist/cli/install/media-pins.d.ts.map +1 -1
  74. package/dist/cli/install/media-pins.js +23 -12
  75. package/dist/cli/install/media-record.d.ts.map +1 -1
  76. package/dist/cli/install/media-record.js +10 -9
  77. package/dist/cli/install/media-report.d.ts +3 -1
  78. package/dist/cli/install/media-report.d.ts.map +1 -1
  79. package/dist/cli/install/media-report.js +7 -5
  80. package/dist/cli/install/media-tools.d.ts.map +1 -1
  81. package/dist/cli/install/media-tools.js +6 -4
  82. package/dist/cli/install/process-start.d.ts +3 -2
  83. package/dist/cli/install/process-start.d.ts.map +1 -1
  84. package/dist/cli/install/process-start.js +4 -0
  85. package/dist/cli/install/report.d.ts.map +1 -1
  86. package/dist/cli/install/report.js +11 -7
  87. package/dist/cli/install/supervisor-install.d.ts +33 -0
  88. package/dist/cli/install/supervisor-install.d.ts.map +1 -0
  89. package/dist/cli/install/supervisor-install.js +158 -0
  90. package/dist/cli/install/supervisor-locate.d.ts +47 -0
  91. package/dist/cli/install/supervisor-locate.d.ts.map +1 -0
  92. package/dist/cli/install/supervisor-locate.js +113 -0
  93. package/dist/cli/install/supervisor-pins.d.ts +21 -0
  94. package/dist/cli/install/supervisor-pins.d.ts.map +1 -0
  95. package/dist/cli/install/supervisor-pins.js +28 -0
  96. package/dist/cli/install/supervisor-record.d.ts +31 -0
  97. package/dist/cli/install/supervisor-record.d.ts.map +1 -0
  98. package/dist/cli/install/supervisor-record.js +104 -0
  99. package/dist/cli/install/supervisor-report.d.ts +11 -0
  100. package/dist/cli/install/supervisor-report.d.ts.map +1 -0
  101. package/dist/cli/install/supervisor-report.js +33 -0
  102. package/dist/cli/install/supervisor-tools.d.ts +43 -0
  103. package/dist/cli/install/supervisor-tools.d.ts.map +1 -0
  104. package/dist/cli/install/supervisor-tools.js +114 -0
  105. package/dist/cli/install/unpack.d.ts +23 -3
  106. package/dist/cli/install/unpack.d.ts.map +1 -1
  107. package/dist/cli/install/unpack.js +124 -10
  108. package/dist/cli/install/zip-member.d.ts +15 -0
  109. package/dist/cli/install/zip-member.d.ts.map +1 -0
  110. package/dist/cli/install/zip-member.js +77 -0
  111. package/dist/cli/interrupt.d.ts +1 -1
  112. package/dist/cli/interrupt.d.ts.map +1 -1
  113. package/dist/cli/interrupt.js +15 -10
  114. package/dist/cli/terminal.d.ts +9 -1
  115. package/dist/cli/terminal.d.ts.map +1 -1
  116. package/dist/cli/terminal.js +14 -0
  117. package/dist/config/define.d.ts +2 -2
  118. package/dist/config/define.js +2 -2
  119. package/dist/diagnostics/firefox-collector.d.ts +11 -10
  120. package/dist/diagnostics/firefox-collector.d.ts.map +1 -1
  121. package/dist/diagnostics/firefox-collector.js +16 -11
  122. package/dist/media/client.d.ts +2 -1
  123. package/dist/media/client.d.ts.map +1 -1
  124. package/dist/media/client.js +16 -5
  125. package/dist/media/host.d.ts +32 -0
  126. package/dist/media/host.d.ts.map +1 -0
  127. package/dist/media/host.js +50 -0
  128. package/dist/media/locate.d.ts.map +1 -1
  129. package/dist/media/locate.js +4 -3
  130. package/dist/native/processes.d.ts.map +1 -1
  131. package/dist/native/processes.js +7 -3
  132. package/dist/protocol/files.d.ts +1 -1
  133. package/dist/protocol/files.d.ts.map +1 -1
  134. package/dist/protocol/files.js +10 -5
  135. package/dist/reporters/commands.d.ts +12 -3
  136. package/dist/reporters/commands.d.ts.map +1 -1
  137. package/dist/reporters/commands.js +29 -11
  138. package/dist/reporters/human-card.js +1 -1
  139. package/dist/reporters/human.d.ts +3 -1
  140. package/dist/reporters/human.d.ts.map +1 -1
  141. package/dist/reporters/human.js +8 -8
  142. package/dist/reporters/style.d.ts +21 -2
  143. package/dist/reporters/style.d.ts.map +1 -1
  144. package/dist/reporters/style.js +11 -2
  145. package/dist/runner/app-server.d.ts +27 -7
  146. package/dist/runner/app-server.d.ts.map +1 -1
  147. package/dist/runner/app-server.js +74 -24
  148. package/dist/runner/child.js +6 -2
  149. package/dist/runner/media-leftovers.d.ts +7 -3
  150. package/dist/runner/media-leftovers.d.ts.map +1 -1
  151. package/dist/runner/media-leftovers.js +27 -6
  152. package/dist/runner/resources.d.ts +8 -0
  153. package/dist/runner/resources.d.ts.map +1 -1
  154. package/dist/runner/resources.js +12 -1
  155. package/dist/runner/run-session.d.ts.map +1 -1
  156. package/dist/runner/run-session.js +9 -4
  157. package/dist/runner/run.d.ts.map +1 -1
  158. package/dist/runner/run.js +2 -1
  159. package/dist/runner/target-drivers.d.ts +5 -2
  160. package/dist/runner/target-drivers.d.ts.map +1 -1
  161. package/dist/runner/target-drivers.js +10 -1
  162. package/dist/runner/test-file-process.d.ts +8 -0
  163. package/dist/runner/test-file-process.d.ts.map +1 -1
  164. package/dist/runner/test-file-process.js +26 -3
  165. package/dist/runner/test-pages.d.ts.map +1 -1
  166. package/dist/runner/test-pages.js +5 -2
  167. package/dist/shared/environment.d.ts +17 -0
  168. package/dist/shared/environment.d.ts.map +1 -0
  169. package/dist/shared/environment.js +27 -0
  170. package/dist/shared/metadata-process-worker.js +15 -7
  171. package/dist/shared/metadata-process.d.ts +5 -0
  172. package/dist/shared/metadata-process.d.ts.map +1 -1
  173. package/dist/shared/metadata-process.js +36 -6
  174. package/dist/shared/posix-path.d.ts +12 -0
  175. package/dist/shared/posix-path.d.ts.map +1 -1
  176. package/dist/shared/posix-path.js +16 -0
  177. package/dist/shared/private-file.d.ts +38 -0
  178. package/dist/shared/private-file.d.ts.map +1 -0
  179. package/dist/shared/private-file.js +132 -0
  180. package/dist/shared/process-ownership.d.ts +52 -2
  181. package/dist/shared/process-ownership.d.ts.map +1 -1
  182. package/dist/shared/process-ownership.js +104 -11
  183. package/dist/shared/process-supervisor.d.ts +309 -0
  184. package/dist/shared/process-supervisor.d.ts.map +1 -0
  185. package/dist/shared/process-supervisor.js +737 -0
  186. package/dist/shared/process-system.d.ts +113 -0
  187. package/dist/shared/process-system.d.ts.map +1 -0
  188. package/dist/shared/process-system.js +254 -0
  189. package/dist/shared/regular-file.d.ts +2 -2
  190. package/dist/shared/regular-file.d.ts.map +1 -1
  191. package/dist/shared/regular-file.js +19 -2
  192. package/dist/store/artifacts.d.ts +5 -4
  193. package/dist/store/artifacts.d.ts.map +1 -1
  194. package/dist/store/artifacts.js +18 -5
  195. package/dist/store/run-store.d.ts +5 -2
  196. package/dist/store/run-store.d.ts.map +1 -1
  197. package/dist/store/run-store.js +48 -4
  198. package/media/Cargo.lock +16 -0
  199. package/media/Cargo.toml +16 -1
  200. package/media/src/encoder.rs +56 -12
  201. package/media/src/frame.rs +7 -1
  202. package/media/src/main.rs +20 -2
  203. package/media/src/place.rs +230 -11
  204. package/media/src/process_ownership_windows.rs +1058 -0
  205. package/media/src/recording.rs +15 -7
  206. package/media/src/replies.rs +55 -0
  207. package/media/src/server.rs +72 -2
  208. package/media/src/store.rs +71 -2
  209. package/package.json +9 -4
  210. package/src/cli/install/media-notices.txt +465 -1
  211. package/supervisor/Cargo.lock +25 -0
  212. package/supervisor/Cargo.toml +22 -0
  213. package/supervisor/NOTICES +464 -0
  214. package/supervisor/build.rs +44 -0
  215. package/supervisor/src/command.rs +612 -0
  216. package/supervisor/src/jobs.rs +1650 -0
  217. package/supervisor/src/main.rs +71 -0
  218. package/supervisor/src/reply.rs +367 -0
@@ -7,6 +7,7 @@ import { diagnosticsFolder, eventsFile, logsFolder, resultFile, statesFolder } f
7
7
  import { parse } from "../protocol/schema.js";
8
8
  import { storageStateSchema } from "../protocol/storage-state.js";
9
9
  import { errorCode } from "../shared/error-code.js";
10
+ import { keepFolderToUser, PrivateFolderError } from "../shared/private-file.js";
10
11
  /** Thrown when the run folder cannot be created or already holds another run. */
11
12
  export class RunFolderError extends Error {
12
13
  name = 'RunFolderError';
@@ -27,6 +28,7 @@ export class RunStore {
27
28
  #resultWritten = false;
28
29
  #closed = false;
29
30
  #removeStatesOnExit;
31
+ #statesReady;
30
32
  // Where the run's clock stands at zero on the host's monotonic clock, as the events written so far place it.
31
33
  #clockZero;
32
34
  #clock;
@@ -140,16 +142,44 @@ export class RunStore {
140
142
  }
141
143
  /**
142
144
  * Saves a sign-in state where only this user can read it, never over another. States hold session cookies, so
143
- * they are removed when the run ends, and when the process exits before it could.
145
+ * they are removed when the run ends, and when the process exits before it could. Their folder is made, by this
146
+ * process alone, and kept to this user before any state is in it (on Windows, whose files take their folder's access
147
+ * list, that is when its list is replaced); each state is then made exclusively and written through the descriptor
148
+ * that made it, so nothing written ever follows a link or lands in a file someone else made.
144
149
  */
145
- writeState(path, state) {
150
+ async writeState(path, state) {
146
151
  const target = this.#path(path);
147
- mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
152
+ await this.#statesFolder(dirname(target));
148
153
  if (this.#removeStatesOnExit === undefined) {
149
154
  this.#removeStatesOnExit = () => this.removeStates();
150
155
  process.once('exit', this.#removeStatesOnExit);
151
156
  }
152
- writeFileSync(target, JSON.stringify(state), { flag: 'wx', mode: 0o600 });
157
+ const descriptor = openSync(target, 'wx', 0o600);
158
+ try {
159
+ writeAll(descriptor, JSON.stringify(state));
160
+ }
161
+ catch (error) {
162
+ closeSync(descriptor);
163
+ throw withLeftover(error, target, () => rmSync(target, { force: true }));
164
+ }
165
+ closeSync(descriptor);
166
+ }
167
+ // The states folder, made once per run by this process: a folder already there is refused, so no one else's folder
168
+ // ever holds a state. It is kept to this user before anything is made in it; a folder that cannot be is removed.
169
+ #statesFolder(folder) {
170
+ this.#statesReady ??= (async () => {
171
+ mkdirSync(folder, { mode: 0o700 });
172
+ try {
173
+ await keepFolderToUser(folder);
174
+ }
175
+ catch (error) {
176
+ throw withLeftover(error, folder, () => rmSync(folder, { recursive: true, force: true }));
177
+ }
178
+ })().catch((error) => {
179
+ this.#statesReady = undefined;
180
+ throw error;
181
+ });
182
+ return this.#statesReady;
153
183
  }
154
184
  /** Reads a state `writeState` saved, checked against its schema. */
155
185
  readState(path) {
@@ -163,6 +193,7 @@ export class RunStore {
163
193
  if (this.#removeStatesOnExit !== undefined)
164
194
  process.removeListener('exit', this.#removeStatesOnExit);
165
195
  this.#removeStatesOnExit = undefined;
196
+ this.#statesReady = undefined;
166
197
  rmSync(this.#path(statesFolder), { recursive: true, force: true });
167
198
  }
168
199
  /** The absolute path of a file inside the run folder. */
@@ -201,6 +232,19 @@ function claim(directory) {
201
232
  throw new RunFolderError(`Retest could not write to the run folder ${directory}: ${errorMessage(error)}`, { cause: error });
202
233
  }
203
234
  }
235
+ // The failure as it was, with what its cleanup could not remove added to it, so a cleanup that fails never replaces it.
236
+ function withLeftover(error, path, remove) {
237
+ try {
238
+ remove();
239
+ return error;
240
+ }
241
+ catch (removal) {
242
+ const leftover = `${path} is still there: ${errorMessage(removal)}`;
243
+ if (error instanceof PrivateFolderError)
244
+ return new PrivateFolderError(error.message, `${error.detail}; ${leftover}`);
245
+ return new Error(`${errorMessage(error)} ${leftover}`, { cause: error });
246
+ }
247
+ }
204
248
  function writeAll(descriptor, text) {
205
249
  const bytes = Buffer.from(text, 'utf8');
206
250
  let offset = 0;
package/media/Cargo.lock CHANGED
@@ -184,6 +184,7 @@ dependencies = [
184
184
  "libc",
185
185
  "serde",
186
186
  "serde_json",
187
+ "windows-sys",
187
188
  ]
188
189
 
189
190
  [[package]]
@@ -252,6 +253,21 @@ version = "1.0.26"
252
253
  source = "registry+https://github.com/rust-lang/crates.io-index"
253
254
  checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
254
255
 
256
+ [[package]]
257
+ name = "windows-link"
258
+ version = "0.2.1"
259
+ source = "registry+https://github.com/rust-lang/crates.io-index"
260
+ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
261
+
262
+ [[package]]
263
+ name = "windows-sys"
264
+ version = "0.61.2"
265
+ source = "registry+https://github.com/rust-lang/crates.io-index"
266
+ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
267
+ dependencies = [
268
+ "windows-link",
269
+ ]
270
+
255
271
  [[package]]
256
272
  name = "zlib-rs"
257
273
  version = "0.6.8"
package/media/Cargo.toml CHANGED
@@ -14,10 +14,25 @@ path = "src/main.rs"
14
14
 
15
15
  [dependencies]
16
16
  image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
17
- libc = "0.2"
18
17
  serde = { version = "1", features = ["derive"] }
19
18
  serde_json = "1"
20
19
 
20
+ [target.'cfg(unix)'.dependencies]
21
+ libc = "0.2"
22
+
23
+ # The encoder's job, its members' identities and their ending, the exclusive rename, positional reads of kept frames,
24
+ # the reply handle and Ctrl+C. Win32_Security is only what CreateJobObjectW's signature names.
25
+ [target.'cfg(windows)'.dependencies]
26
+ windows-sys = { version = "0.61", features = [
27
+ "Win32_Foundation",
28
+ "Win32_Security",
29
+ "Win32_Storage_FileSystem",
30
+ "Win32_System_Console",
31
+ "Win32_System_Diagnostics_ToolHelp",
32
+ "Win32_System_JobObjects",
33
+ "Win32_System_Threading",
34
+ ] }
35
+
21
36
  [features]
22
37
  # Counts heap allocations and prints them at exit, for measuring one route against another. Never in a shipped build.
23
38
  allocation-counts = []
@@ -9,7 +9,10 @@
9
9
  use std::collections::VecDeque;
10
10
  use std::ffi::OsString;
11
11
  use std::io::{self, Read};
12
+ #[cfg(unix)]
12
13
  use std::os::unix::process::CommandExt;
14
+ #[cfg(windows)]
15
+ use std::os::windows::process::CommandExt;
13
16
  use std::path::Path;
14
17
  use std::process::{Child, Command, ExitStatus, Stdio};
15
18
  use std::sync::{Arc, Mutex};
@@ -205,8 +208,39 @@ pub fn encode_arguments(
205
208
  arguments
206
209
  }
207
210
 
211
+ /// The variables an encoder inherits: where programs and temporary files are. Windows programs also expect the
212
+ /// system folder's variable, and name their temporary folder differently.
213
+ #[cfg(unix)]
214
+ const ENCODER_VARIABLES: [&str; 2] = ["PATH", "TMPDIR"];
215
+ #[cfg(windows)]
216
+ const ENCODER_VARIABLES: [&str; 4] = ["PATH", "SystemRoot", "TEMP", "TMP"];
217
+
218
+ /// Starts the encoder and the ownership that will record it. On Unix the encoder leads a process group of its own; on
219
+ /// Windows it is created suspended and runs only once it is in a job of its own, so everything it starts is in that job
220
+ /// from birth. A Windows encoder that cannot be put in a job is ended through its own handle before it ever ran.
221
+ pub fn spawn_owned(
222
+ ffmpeg: &Path,
223
+ arguments: &[OsString],
224
+ stdin: Stdio,
225
+ stdout: Stdio,
226
+ ) -> io::Result<(Child, Ownership)> {
227
+ let mut child = spawn_in_group(ffmpeg, arguments, stdin, stdout)?;
228
+ #[cfg(unix)]
229
+ let held: io::Result<Ownership> = Ok(Ownership::new(child.id()));
230
+ #[cfg(windows)]
231
+ let held: io::Result<Ownership> = Ownership::hold(&mut child);
232
+ match held {
233
+ Ok(ownership) => Ok((child, ownership)),
234
+ Err(error) => {
235
+ let _ = child.kill();
236
+ let _ = child.wait();
237
+ Err(error)
238
+ }
239
+ }
240
+ }
241
+
208
242
  /// Starts the encoder in its own process group. Ownership is recorded separately before any cleanup signal.
209
- pub fn spawn_in_group(
243
+ fn spawn_in_group(
210
244
  ffmpeg: &Path,
211
245
  arguments: &[OsString],
212
246
  stdin: Stdio,
@@ -217,16 +251,29 @@ pub fn spawn_in_group(
217
251
  if ffmpeg.components().count() > 1 {
218
252
  std::fs::metadata(ffmpeg)?;
219
253
  }
220
- Command::new(ffmpeg)
254
+ let mut command = Command::new(ffmpeg);
255
+ command
221
256
  .env_clear()
222
257
  .envs(
223
- ["PATH", "TMPDIR"]
258
+ ENCODER_VARIABLES
224
259
  .into_iter()
225
260
  .filter_map(|name| std::env::var_os(name).map(|value| (name, value))),
226
261
  )
227
262
  .env("LC_ALL", "C")
228
- .args(arguments)
229
- .process_group(0)
263
+ .args(arguments);
264
+ #[cfg(unix)]
265
+ command.process_group(0);
266
+ // The encoder gets a console of its own without a window: nothing appears on screen, a Ctrl+C typed in another
267
+ // console never reaches it, so, as on Unix, only the media process ends it, and the programs a wrapper starts
268
+ // share that console and the wrapper's pipes. Without any console, a wrapper's child would get a new console in
269
+ // place of those pipes. Windows signals no process group; the encoder starts suspended instead, so `spawn_owned`
270
+ // can put it in a job before it runs.
271
+ #[cfg(windows)]
272
+ command.creation_flags(
273
+ windows_sys::Win32::System::Threading::CREATE_NO_WINDOW
274
+ | windows_sys::Win32::System::Threading::CREATE_SUSPENDED,
275
+ );
276
+ command
230
277
  .stdin(stdin)
231
278
  .stdout(stdout)
232
279
  .stderr(Stdio::piped())
@@ -328,14 +375,11 @@ struct Listing {
328
375
  // what was asked: `asked` names it, such as "its encoders".
329
376
  fn listing(ffmpeg: &Path, arguments: &[&str], asked: &str) -> Result<Listing, EncoderFailure> {
330
377
  let arguments: Vec<OsString> = arguments.iter().map(OsString::from).collect();
331
- let mut child =
332
- spawn_in_group(ffmpeg, &arguments, Stdio::null(), Stdio::piped()).map_err(|error| {
333
- EncoderFailure {
334
- message: format!("{} could not be started: {error}", ffmpeg.display()),
335
- exit: None,
336
- }
378
+ let (mut child, mut ownership) = spawn_owned(ffmpeg, &arguments, Stdio::null(), Stdio::piped())
379
+ .map_err(|error| EncoderFailure {
380
+ message: format!("{} could not be started: {error}", ffmpeg.display()),
381
+ exit: None,
337
382
  })?;
338
- let mut ownership = Ownership::new(child.id());
339
383
  let mut problems = ownership.capture();
340
384
  let stdout = child.stdout.take().map(read_bounded);
341
385
  let stderr = child.stderr.take().map(read_bounded);
@@ -781,10 +781,16 @@ mod tests {
781
781
  let candidates: Vec<std::path::PathBuf> = if path.components().count() > 1 {
782
782
  vec![path.to_owned()]
783
783
  } else {
784
+ // Windows finds a program on PATH by its name with `.exe`.
785
+ let name = if cfg!(windows) && path.extension().is_none() {
786
+ path.with_extension("exe")
787
+ } else {
788
+ path.to_owned()
789
+ };
784
790
  std::env::split_paths(
785
791
  &std::env::var_os("PATH").expect("PATH for the ffmpeg prerequisite"),
786
792
  )
787
- .map(|folder| folder.join(path))
793
+ .map(|folder| folder.join(&name))
788
794
  .collect()
789
795
  };
790
796
  let ffmpeg = candidates
package/media/src/main.rs CHANGED
@@ -5,8 +5,10 @@
5
5
  //! recording's kept frames for an interval, and sends a live view of a recording's newest frame. The protocol is in
6
6
  //! `protocol.rs`.
7
7
 
8
- #[cfg(not(unix))]
9
- compile_error!("retest-media runs on Unix: it verifies each encoder process before stopping it");
8
+ #[cfg(not(any(unix, windows)))]
9
+ compile_error!(
10
+ "retest-media runs on Unix and Windows: it verifies each encoder process before stopping it"
11
+ );
10
12
 
11
13
  #[cfg(feature = "allocation-counts")]
12
14
  mod allocations;
@@ -16,7 +18,10 @@ mod jobs;
16
18
  mod ledger;
17
19
  mod live;
18
20
  mod place;
21
+ #[cfg(unix)]
19
22
  mod process_ownership;
23
+ #[cfg(windows)]
24
+ mod process_ownership_windows;
20
25
  mod protocol;
21
26
  mod queue;
22
27
  mod recording;
@@ -25,6 +30,11 @@ mod server;
25
30
  mod store;
26
31
  mod timeline;
27
32
 
33
+ // Windows has no `ps` and no signals: an encoder is witnessed by a job of its own, and each process in it by its own
34
+ // handle.
35
+ #[cfg(windows)]
36
+ use process_ownership_windows as process_ownership;
37
+
28
38
  use std::ffi::OsString;
29
39
  use std::io::{self, BufReader};
30
40
  use std::path::PathBuf;
@@ -54,6 +64,14 @@ fn main() -> ExitCode {
54
64
  return ExitCode::from(2);
55
65
  }
56
66
  };
67
+ // A media process started in its runner's console would end at the first Ctrl+C typed there, before the runner
68
+ // could finish its recordings. As on Unix, where it is not in the terminal's process group, the runner ends it.
69
+ // Its encoders and finalizers have consoles of their own and hear no Ctrl+C either.
70
+ #[cfg(windows)]
71
+ // SAFETY: a null handler with TRUE only sets this process's own flag to ignore Ctrl+C.
72
+ unsafe {
73
+ windows_sys::Win32::System::Console::SetConsoleCtrlHandler(None, 1);
74
+ }
57
75
  let replies = match replies::stdout_pipe().and_then(|output| Replies::new(Box::new(output))) {
58
76
  Ok(replies) => replies,
59
77
  Err(error) => {
@@ -4,10 +4,16 @@
4
4
  //! as FAT or exFAT, refuses the link; then the file is copied to `<path>.copying` beside it, synced, and renamed to
5
5
  //! the path with a rename that refuses to replace a file, so nothing is ever at the path but a whole file. A process
6
6
  //! killed during the copy leaves the `.copying` file, which `leftovers` lists and removes.
7
+ //!
8
+ //! Windows refuses to rename or remove a file while another program holds it open without allowing that, as a virus
9
+ //! scanner reading a new file briefly does. There each move and removal waits a bounded time for the file to be let go,
10
+ //! and a failure names the file as open in another program.
7
11
 
8
12
  use std::fs::{self, File, OpenOptions};
9
13
  use std::io::{self, Read, Write};
10
14
  use std::path::{Path, PathBuf};
15
+ #[cfg(windows)]
16
+ use std::time::{Duration, Instant};
11
17
 
12
18
  use serde::{Deserialize, Serialize};
13
19
 
@@ -51,7 +57,7 @@ pub fn place(partial: &Path, path: &Path) -> io::Result<Placed> {
51
57
  /// Publishes a complete file without removing its original partial.
52
58
  pub fn retain_source(partial: &Path, path: &Path) -> io::Result<Placed> {
53
59
  delayed_if_a_test_asks();
54
- match link(partial, path) {
60
+ match while_in_use(partial, || link(partial, path)) {
55
61
  Ok(()) => Ok(Placed::Link),
56
62
  Err(error) if links_unavailable(&error) => {
57
63
  copy_exclusively(partial, path)?;
@@ -85,7 +91,7 @@ pub fn finalize_file(partial: &Path, path: &Path) -> Finalization {
85
91
  }
86
92
  }
87
93
  match retain_source(partial, path) {
88
- Ok(placed) => match fs::remove_file(partial) {
94
+ Ok(placed) => match while_in_use(partial, || fs::remove_file(partial)) {
89
95
  Ok(()) => Finalization::Placed(placed),
90
96
  Err(error) => Finalization::Failed(format!(
91
97
  "the video was placed at {}, but its source at {} could not be removed: {error}",
@@ -118,7 +124,7 @@ fn delayed_if_a_test_asks() {}
118
124
  #[cfg(debug_assertions)]
119
125
  fn link(partial: &Path, path: &Path) -> io::Result<()> {
120
126
  if std::env::var_os("RETEST_MEDIA_TEST_NO_HARD_LINKS").is_some() {
121
- return Err(io::Error::from_raw_os_error(libc::ENOTSUP));
127
+ return Err(io::Error::from_raw_os_error(NOT_SUPPORTED));
122
128
  }
123
129
  fs::hard_link(partial, path)
124
130
  }
@@ -135,10 +141,86 @@ fn links_unavailable(error: &io::Error) -> bool {
135
141
  }
136
142
  matches!(
137
143
  error.raw_os_error(),
138
- Some(code) if [libc::EPERM, libc::ENOTSUP, libc::EOPNOTSUPP, libc::EXDEV, libc::EMLINK, libc::ENOSYS].contains(&code)
144
+ Some(code) if LINKS_UNAVAILABLE.contains(&code)
139
145
  )
140
146
  }
141
147
 
148
+ // The codes a failed link or rename gives when the filesystem cannot do it at all, rather than because of what is at
149
+ // the path, as each host numbers them.
150
+ #[cfg(unix)]
151
+ const LINKS_UNAVAILABLE: [i32; 6] = [
152
+ libc::EPERM,
153
+ libc::ENOTSUP,
154
+ libc::EOPNOTSUPP,
155
+ libc::EXDEV,
156
+ libc::EMLINK,
157
+ libc::ENOSYS,
158
+ ];
159
+ #[cfg(unix)]
160
+ const RENAME_UNSUPPORTED: [i32; 4] = [libc::ENOTSUP, libc::EOPNOTSUPP, libc::EINVAL, libc::ENOSYS];
161
+ // What the test hook that hides hard links answers.
162
+ #[cfg(all(unix, any(debug_assertions, test)))]
163
+ const NOT_SUPPORTED: i32 = libc::ENOTSUP;
164
+
165
+ // FAT and exFAT answer a hard link with ERROR_INVALID_FUNCTION, a share without links with ERROR_NOT_SUPPORTED.
166
+ #[cfg(windows)]
167
+ const LINKS_UNAVAILABLE: [i32; 5] = [
168
+ windows_sys::Win32::Foundation::ERROR_INVALID_FUNCTION as i32,
169
+ windows_sys::Win32::Foundation::ERROR_NOT_SUPPORTED as i32,
170
+ windows_sys::Win32::Foundation::ERROR_NOT_SAME_DEVICE as i32,
171
+ windows_sys::Win32::Foundation::ERROR_TOO_MANY_LINKS as i32,
172
+ windows_sys::Win32::Foundation::ERROR_CALL_NOT_IMPLEMENTED as i32,
173
+ ];
174
+ #[cfg(windows)]
175
+ const RENAME_UNSUPPORTED: [i32; 3] = [
176
+ windows_sys::Win32::Foundation::ERROR_INVALID_FUNCTION as i32,
177
+ windows_sys::Win32::Foundation::ERROR_NOT_SUPPORTED as i32,
178
+ windows_sys::Win32::Foundation::ERROR_CALL_NOT_IMPLEMENTED as i32,
179
+ ];
180
+ #[cfg(all(windows, any(debug_assertions, test)))]
181
+ const NOT_SUPPORTED: i32 = windows_sys::Win32::Foundation::ERROR_NOT_SUPPORTED as i32;
182
+
183
+ /// How long a move or removal waits for another program to let go of a file on Windows.
184
+ #[cfg(windows)]
185
+ const IN_USE_WAIT: Duration = Duration::from_secs(2);
186
+
187
+ // Runs a move or removal of `path` again while another program holds the file open against it, up to `IN_USE_WAIT`.
188
+ // A file still held then is named as open in another program.
189
+ #[cfg(windows)]
190
+ fn while_in_use<T>(path: &Path, mut operation: impl FnMut() -> io::Result<T>) -> io::Result<T> {
191
+ use windows_sys::Win32::Foundation::{ERROR_LOCK_VIOLATION, ERROR_SHARING_VIOLATION};
192
+ let started = Instant::now();
193
+ loop {
194
+ match operation() {
195
+ Err(error)
196
+ if matches!(
197
+ error.raw_os_error(),
198
+ Some(code) if code == ERROR_SHARING_VIOLATION as i32 || code == ERROR_LOCK_VIOLATION as i32
199
+ ) =>
200
+ {
201
+ if started.elapsed() >= IN_USE_WAIT {
202
+ return Err(io::Error::new(
203
+ error.kind(),
204
+ format!(
205
+ "{} is open in another program, which kept it from being moved or removed for {} s: {error}",
206
+ path.display(),
207
+ IN_USE_WAIT.as_secs()
208
+ ),
209
+ ));
210
+ }
211
+ std::thread::sleep(Duration::from_millis(50));
212
+ }
213
+ done => return done,
214
+ }
215
+ }
216
+ }
217
+
218
+ // A Unix file that is open can always be renamed and removed.
219
+ #[cfg(unix)]
220
+ fn while_in_use<T>(_: &Path, mut operation: impl FnMut() -> io::Result<T>) -> io::Result<T> {
221
+ operation()
222
+ }
223
+
142
224
  fn copy_exclusively(partial: &Path, path: &Path) -> io::Result<()> {
143
225
  if path.symlink_metadata().is_ok() {
144
226
  return Err(io::Error::from(io::ErrorKind::AlreadyExists));
@@ -165,7 +247,12 @@ fn copy_exclusively(partial: &Path, path: &Path) -> io::Result<()> {
165
247
  // operation is supported, placement fails with the destination named and keeps the source. Ordinary rename is
166
248
  // never used because it can replace a foreign file between a check and publication.
167
249
  fn rename_without_replacing(from: &Path, to: &Path) -> io::Result<()> {
168
- rename_using(from, to, exclusive_rename, || {})
250
+ rename_using(
251
+ from,
252
+ to,
253
+ |from, to| while_in_use(from, || exclusive_rename(from, to)),
254
+ || {},
255
+ )
169
256
  }
170
257
 
171
258
  fn rename_using(
@@ -178,7 +265,7 @@ fn rename_using(
178
265
  Err(error)
179
266
  if matches!(
180
267
  error.raw_os_error(),
181
- Some(code) if [libc::ENOTSUP, libc::EOPNOTSUPP, libc::EINVAL, libc::ENOSYS].contains(&code)
268
+ Some(code) if RENAME_UNSUPPORTED.contains(&code)
182
269
  ) =>
183
270
  {
184
271
  if to.symlink_metadata().is_ok() {
@@ -233,7 +320,34 @@ fn exclusive_rename(from: &Path, to: &Path) -> io::Result<()> {
233
320
  }
234
321
  }
235
322
 
236
- #[cfg(not(any(target_os = "macos", target_os = "linux")))]
323
+ // MoveFileExW without MOVEFILE_REPLACE_EXISTING fails when anything is at the destination, the same refusal as
324
+ // RENAME_EXCL and RENAME_NOREPLACE.
325
+ #[cfg(windows)]
326
+ fn exclusive_rename(from: &Path, to: &Path) -> io::Result<()> {
327
+ let (from, to) = (wide_path(from)?, wide_path(to)?);
328
+ // SAFETY: MoveFileExW takes two NUL-terminated UTF-16 paths that live across the call, and no flags.
329
+ let moved = unsafe {
330
+ windows_sys::Win32::Storage::FileSystem::MoveFileExW(from.as_ptr(), to.as_ptr(), 0)
331
+ };
332
+ if moved != 0 {
333
+ Ok(())
334
+ } else {
335
+ Err(io::Error::last_os_error())
336
+ }
337
+ }
338
+
339
+ #[cfg(windows)]
340
+ fn wide_path(path: &Path) -> io::Result<Vec<u16>> {
341
+ use std::os::windows::ffi::OsStrExt;
342
+ let mut wide: Vec<u16> = path.as_os_str().encode_wide().collect();
343
+ if wide.contains(&0) {
344
+ return Err(io::Error::from(io::ErrorKind::InvalidInput));
345
+ }
346
+ wide.push(0);
347
+ Ok(wide)
348
+ }
349
+
350
+ #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
237
351
  fn exclusive_rename(_: &Path, _: &Path) -> io::Result<()> {
238
352
  Err(io::Error::from_raw_os_error(libc::ENOTSUP))
239
353
  }
@@ -259,8 +373,9 @@ fn copy_in_chunks(source: &mut File, target: &mut File) -> io::Result<()> {
259
373
  }
260
374
  }
261
375
 
262
- // A test sets this to prove what a process killed in the middle of a copy leaves behind. Release builds have no hook.
263
- #[cfg(debug_assertions)]
376
+ // A test sets this to prove what a process killed in the middle of a copy leaves behind. Release builds have no hook,
377
+ // and neither has Windows, where no test asks.
378
+ #[cfg(all(debug_assertions, unix))]
264
379
  fn killed_if_a_test_asks() {
265
380
  if std::env::var_os("RETEST_MEDIA_TEST_KILL_DURING_COPY").is_some() {
266
381
  // SAFETY: kill(2) on this process's own pid; SIGKILL leaves exactly what a kill from outside would.
@@ -270,7 +385,7 @@ fn killed_if_a_test_asks() {
270
385
  }
271
386
  }
272
387
 
273
- #[cfg(not(debug_assertions))]
388
+ #[cfg(not(all(debug_assertions, unix)))]
274
389
  fn killed_if_a_test_asks() {}
275
390
 
276
391
  #[cfg(test)]
@@ -349,7 +464,7 @@ mod tests {
349
464
  let result = rename_using(
350
465
  &copying,
351
466
  &path,
352
- |_, _| Err(io::Error::from_raw_os_error(libc::ENOTSUP)),
467
+ |_, _| Err(io::Error::from_raw_os_error(NOT_SUPPORTED)),
353
468
  || {
354
469
  barrier.wait();
355
470
  barrier.wait();
@@ -391,6 +506,7 @@ mod tests {
391
506
  let _ = fs::remove_dir_all(folder);
392
507
  }
393
508
 
509
+ #[cfg(unix)]
394
510
  #[test]
395
511
  fn only_link_refusals_choose_the_copy() {
396
512
  for code in [libc::EPERM, libc::ENOTSUP, libc::EXDEV, libc::EMLINK] {
@@ -406,4 +522,107 @@ mod tests {
406
522
  );
407
523
  }
408
524
  }
525
+
526
+ #[cfg(windows)]
527
+ #[test]
528
+ fn only_link_refusals_choose_the_copy_on_windows() {
529
+ use windows_sys::Win32::Foundation::{
530
+ ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, ERROR_DISK_FULL, ERROR_FILE_NOT_FOUND,
531
+ ERROR_INVALID_FUNCTION, ERROR_NOT_SAME_DEVICE, ERROR_NOT_SUPPORTED,
532
+ ERROR_TOO_MANY_LINKS,
533
+ };
534
+ for code in [
535
+ ERROR_INVALID_FUNCTION,
536
+ ERROR_NOT_SUPPORTED,
537
+ ERROR_NOT_SAME_DEVICE,
538
+ ERROR_TOO_MANY_LINKS,
539
+ ] {
540
+ assert!(
541
+ links_unavailable(&io::Error::from_raw_os_error(code as i32)),
542
+ "{code}"
543
+ );
544
+ }
545
+ for code in [
546
+ ERROR_ALREADY_EXISTS,
547
+ ERROR_FILE_NOT_FOUND,
548
+ ERROR_ACCESS_DENIED,
549
+ ERROR_DISK_FULL,
550
+ ] {
551
+ assert!(
552
+ !links_unavailable(&io::Error::from_raw_os_error(code as i32)),
553
+ "{code}"
554
+ );
555
+ }
556
+ }
557
+
558
+ // Opens `path` as a program that lets others read it but neither rename nor remove it, and lets go after `hold`.
559
+ #[cfg(windows)]
560
+ fn held_open(path: &Path, hold: std::time::Duration) -> std::thread::JoinHandle<()> {
561
+ use std::os::windows::fs::OpenOptionsExt;
562
+ let file = OpenOptions::new()
563
+ .read(true)
564
+ .share_mode(windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ)
565
+ .open(path)
566
+ .expect("the other program opens the file");
567
+ std::thread::spawn(move || {
568
+ std::thread::sleep(hold);
569
+ drop(file);
570
+ })
571
+ }
572
+
573
+ #[cfg(windows)]
574
+ #[test]
575
+ fn a_partial_another_program_holds_open_is_placed_once_it_is_let_go() {
576
+ let folder = folder("held-briefly");
577
+ let path = folder.join("video.mp4");
578
+ let partial = partial_path_of(&path);
579
+ fs::write(&partial, b"video").expect("written");
580
+ let other = held_open(&partial, std::time::Duration::from_millis(400));
581
+ let placed = finalize_file(&partial, &path);
582
+ other.join().expect("the other program lets go");
583
+ assert!(
584
+ matches!(placed, Finalization::Placed(Placed::Link)),
585
+ "{placed:?}"
586
+ );
587
+ assert_eq!(fs::read(&path).expect("there"), b"video");
588
+ assert!(!partial.exists());
589
+ let _ = fs::remove_dir_all(folder);
590
+ }
591
+
592
+ #[cfg(windows)]
593
+ #[test]
594
+ fn a_partial_held_open_past_the_wait_is_named_as_open_in_another_program() {
595
+ let folder = folder("held-long");
596
+ let path = folder.join("video.mp4");
597
+ let partial = partial_path_of(&path);
598
+ fs::write(&partial, b"video").expect("written");
599
+ let other = held_open(&partial, IN_USE_WAIT + std::time::Duration::from_secs(1));
600
+ let placed = finalize_file(&partial, &path);
601
+ other.join().expect("the other program lets go");
602
+ let Finalization::Failed(message) = placed else {
603
+ panic!("a source still held open is not removed: {placed:?}");
604
+ };
605
+ assert!(message.contains("is open in another program"), "{message}");
606
+ assert!(
607
+ message.contains(&partial.display().to_string()),
608
+ "{message}"
609
+ );
610
+ assert_eq!(fs::read(&path).expect("the video is in place"), b"video");
611
+ let _ = fs::remove_dir_all(folder);
612
+ }
613
+
614
+ #[cfg(windows)]
615
+ #[test]
616
+ fn a_copy_another_program_holds_open_is_renamed_once_it_is_let_go() {
617
+ let folder = folder("copy-held");
618
+ let path = folder.join("video.mp4");
619
+ let copying = copying_path_of(&path);
620
+ fs::write(&copying, b"whole").expect("written");
621
+ let other = held_open(&copying, std::time::Duration::from_millis(400));
622
+ rename_without_replacing(&copying, &path).expect("renamed once let go");
623
+ other.join().expect("the other program lets go");
624
+ assert_eq!(fs::read(&path).expect("there"), b"whole");
625
+ assert!(!copying.exists());
626
+ let _ = fs::remove_dir_all(folder);
627
+ }
409
628
  }