Skip to main content

tuwunel_service/media/
video.rs

1//! Video Thumbnails
2//!
3//! Tuwunel decodes no video itself. An operator-configured program extracts a
4//! still frame, which the image thumbnailer then treats as the source picture.
5
6use std::{
7	borrow::Cow,
8	fs::{read_dir, remove_file},
9	io,
10	path::Path,
11	process::Stdio,
12	time::Duration,
13};
14
15use futures::future::try_join;
16#[cfg(unix)]
17use libc::{SIGKILL, killpg};
18use lru_cache::LruCache;
19use ruma::Mxc;
20use tokio::{
21	fs::{OpenOptions, create_dir_all},
22	io::{AsyncRead, AsyncReadExt, AsyncWriteExt, copy, sink},
23	process::Command,
24	time::{Instant, timeout_at},
25};
26use tuwunel_core::{
27	Config, Err, Result, debug, debug_warn, defer, err, implement,
28	utils::{BoolExt, random_string},
29};
30
31use super::{Dim, Media};
32
33/// Tokens replaced in every configured argument before each call.
34const INPUT: &str = "{input}";
35const WIDTH: &str = "{width}";
36const HEIGHT: &str = "{height}";
37
38/// Content-type prefix of media a still frame is extracted from.
39const VIDEO: &str = "video/";
40
41/// Distinguishes a staged video from anything else in the staging directory,
42/// so the startup sweep reclaims only what this module wrote.
43const STAGED: &str = "tuwunel-video-";
44
45const NAME_LENGTH: usize = 16;
46
47const DIAGNOSTIC_LEN: u64 = 4096;
48
49/// How long a video whose extraction failed is left alone. Retrying it at once
50/// would spend a slot to fail again, and at the default concurrency of one that
51/// is the slot every other video is waiting for.
52const FAILURE_COOLDOWN: Duration = Duration::from_mins(5);
53
54/// Videos remembered as having failed.
55pub(super) const FAILURES: usize = 1024;
56
57/// Fraction of the deadline the program must still have to be worth running,
58/// and to be answerable for missing.
59const FAIR_SHARE: f64 = 4.0;
60
61/// Videos whose frame extraction failed, against the time it last did.
62/// Bounded, since forgetting an entry early only costs a retry.
63pub(super) type Failures = LruCache<String, Instant>;
64
65/// Kills the program's process group on every exit that leaves it running, so
66/// that a shutdown or a disconnected client takes a wrapper's descendants with
67/// it exactly as an expired deadline does. Disarmed once the child is reaped,
68/// past which the identifier could name a group this server never spawned.
69struct Reaper {
70	group: Option<u32>,
71}
72
73impl Reaper {
74	fn disarm(&mut self) { self.group = None; }
75}
76
77impl Drop for Reaper {
78	fn drop(&mut self) {
79		if let Some(group) = self.group {
80			kill_group(group);
81		}
82	}
83}
84
85/// Still frame standing in for a video, or `None` when the media is not a
86/// video, no program is configured, or extraction failed.
87#[implement(super::Service)]
88#[tracing::instrument(
89	name = "video",
90	level = "debug",
91	skip_all,
92	fields(
93		?dim,
94		content_type = ?media.content_type,
95	),
96)]
97pub(super) async fn video_frame(
98	&self,
99	mxc: &Mxc<'_>,
100	dim: &Dim,
101	media: &Media,
102) -> Option<Vec<u8>> {
103	let config = &self.services.config;
104
105	let is_video = media
106		.content_type
107		.as_deref()
108		.is_some_and(|content_type| content_type.starts_with(VIDEO));
109
110	let workable = !config.media_video_thumbnail_command.is_empty()
111		&& media.content.len() <= config.media_video_thumbnail_max_size
112		&& !self.failed_recently(mxc);
113
114	is_video
115		.and_is(workable)
116		.then_async(|| self.extract_frame(mxc, dim, &media.content))
117		.await?
118		.inspect_err(|e| debug_warn!(%e, "Failed to extract a video frame."))
119		.ok()
120}
121
122/// Whether this video failed within the cooldown, where trying again would
123/// spend a slot to reach the same failure.
124#[implement(super::Service)]
125fn failed_recently(&self, mxc: &Mxc<'_>) -> bool {
126	let Some(cooldown) = Instant::now().checked_sub(FAILURE_COOLDOWN) else {
127		return false;
128	};
129
130	self.video_thumbnail_failures
131		.lock()
132		.ok()
133		.and_then(|mut failures| failures.get_mut(&mxc.to_string()).copied())
134		.is_some_and(|failed| failed > cooldown)
135}
136
137#[implement(super::Service)]
138pub(super) fn remember_failure(&self, mxc: &Mxc<'_>) {
139	if let Ok(mut failures) = self.video_thumbnail_failures.lock() {
140		failures.insert(mxc.to_string(), Instant::now());
141	}
142}
143
144#[implement(super::Service)]
145#[tracing::instrument(level = "debug", skip(self, content))]
146async fn extract_frame(&self, mxc: &Mxc<'_>, dim: &Dim, content: &[u8]) -> Result<Vec<u8>> {
147	let config = &self.services.config;
148	let timeout = Duration::from_secs(config.media_video_thumbnail_timeout);
149
150	// one deadline spans the wait for a slot, the staging write and the program,
151	// so a queue cannot compound into a multiple of the configured timeout
152	let deadline = Instant::now()
153		.checked_add(timeout)
154		.ok_or_else(|| {
155			err!(Config("media_video_thumbnail_timeout", "Timeout is out of range."))
156		})?;
157
158	let Some((program, args)) = config.media_video_thumbnail_command.split_first() else {
159		return Err!(Config("media_video_thumbnail_command", "No program is configured."));
160	};
161
162	let Ok(Ok(_slot)) = timeout_at(deadline, self.video_thumbnail_slots.acquire()).await else {
163		return Err!("Timed out waiting for a video thumbnail slot.");
164	};
165
166	let path = staging_dir(config).join(format!("{STAGED}{}", random_string(NAME_LENGTH)));
167
168	defer! {{ remove_file(&path).ok(); }}
169
170	let Ok(staged) = timeout_at(deadline, stage(&path, content)).await else {
171		return Err!("Timed out staging the video.");
172	};
173
174	staged?;
175
176	// a residue of the deadline is not a fair trial: the program would fail on
177	// time the queue spent, and be remembered below for someone else's load
178	if deadline.saturating_duration_since(Instant::now()) < timeout.div_f64(FAIR_SHARE) {
179		return Err!("Too little of the deadline remained to run the video thumbnail program.");
180	}
181
182	let width = dim.width.to_string();
183	let height = dim.height.to_string();
184	let args = args
185		.iter()
186		.map(|arg| substitute(arg, &path, &width, &height));
187
188	let limit = u64::try_from(config.media_video_thumbnail_max_size).unwrap_or(u64::MAX);
189	let frame = run(program, args, limit, deadline).await;
190
191	// the program reached a verdict on this video, so a failure is the video's
192	// and worth remembering; the paths above are contention and say nothing
193	if frame.is_err() {
194		self.remember_failure(mxc);
195	}
196
197	frame
198}
199
200/// Videos are staged beside the database rather than in the system temporary
201/// directory, which is commonly memory-backed and sized for small files.
202fn staging_dir(config: &Config) -> Cow<'_, Path> {
203	config
204		.media_video_thumbnail_path
205		.as_deref()
206		.map_or_else(|| config.database_path.join("tmp").into(), Cow::Borrowed)
207}
208
209/// Reclaim videos staged by a previous run: the scope guard that unlinks them
210/// survives neither a kill nor the exec of an in-place restart. Runs during
211/// construction, before a request can stage a file this would race, which is
212/// also before the service graph exists to read the configuration through.
213#[tracing::instrument(name = "sweep", level = "debug", skip_all)]
214pub(super) fn sweep_staging_dir(config: &Config) {
215	let Ok(dir) = read_dir(staging_dir(config).as_ref()) else {
216		return;
217	};
218
219	dir.filter_map(Result::ok)
220		.map(|entry| entry.path())
221		.filter(|path| {
222			path.file_name()
223				.and_then(|name| name.to_str())
224				.is_some_and(|name| name.starts_with(STAGED))
225		})
226		.for_each(|path| {
227			debug!(?path, "Removing a video staged by a previous run.");
228			remove_file(&path).ok();
229		});
230}
231
232/// Write the video to a fresh private file: the program needs a seekable
233/// input, and a pipe cannot serve formats whose index trails the media.
234async fn stage(path: &Path, content: &[u8]) -> Result {
235	if let Some(dir) = path.parent() {
236		create_dir_all(dir).await?;
237	}
238
239	let mut options = OpenOptions::new();
240	options.create_new(true).write(true);
241
242	#[cfg(unix)]
243	options.mode(0o600);
244
245	let mut file = options.open(path).await?;
246
247	file.write_all(content).await?;
248	file.flush().await?;
249
250	Ok(())
251}
252
253pub(super) fn substitute<'a>(
254	arg: &'a str,
255	input: &Path,
256	width: &str,
257	height: &str,
258) -> Cow<'a, str> {
259	let substituted = || {
260		arg.replace(INPUT, &input.to_string_lossy())
261			.replace(WIDTH, width)
262			.replace(HEIGHT, height)
263			.into()
264	};
265
266	arg.contains('{')
267		.then(substituted)
268		.unwrap_or(Cow::Borrowed(arg))
269}
270
271/// Collect the frame the program writes to standard output, refusing one past
272/// the limit rather than decoding a truncation. The program runs in its own
273/// process group, so a wrapper overrunning the deadline cannot orphan the work
274/// it spawned.
275#[tracing::instrument(
276	level = "debug",
277	skip(args),
278	fields(
279		%program,
280		%limit,
281	),
282)]
283pub(super) async fn run<Args>(
284	program: &str,
285	args: Args,
286	limit: u64,
287	deadline: Instant,
288) -> Result<Vec<u8>>
289where
290	Args: IntoIterator<Item: AsRef<str>> + Send,
291{
292	let mut command = Command::new(program);
293	command
294		.stdin(Stdio::null())
295		.stdout(Stdio::piped())
296		.stderr(Stdio::piped())
297		.kill_on_drop(true);
298
299	for arg in args {
300		command.arg(arg.as_ref());
301	}
302
303	#[cfg(unix)]
304	command.process_group(0);
305
306	let mut child = command.spawn()?;
307	let mut reaper = Reaper { group: child.id() };
308
309	let stdout = child.stdout.take().expect("stdout is piped");
310	let stderr = child.stderr.take().expect("stderr is piped");
311
312	// one byte past the limit distinguishes an exact fit from a truncation
313	let collect = try_join(drain(stdout, limit.saturating_add(1)), drain(stderr, DIAGNOSTIC_LEN));
314
315	// the child is reaped only once both pipes reach EOF, so that a wrapper
316	// exiting while a descendant still holds them does not read as finished
317	let outcome = timeout_at(deadline, async {
318		let collected = collect.await?;
319		let status = child.wait().await?;
320
321		Ok::<_, io::Error>((collected, status))
322	})
323	.await;
324
325	// tokio clears the identifier on reaping, which by the above means nothing
326	// still holds the pipes; signalling the group past that point could reach
327	// one this server never spawned, so let a descendant that closed them go
328	if child.id().is_none() {
329		reaper.disarm();
330	}
331
332	let Ok(exited) = outcome else {
333		return Err!("Video thumbnail program exceeded its deadline.");
334	};
335
336	let ((frame, diagnostic), status) = exited?;
337	let diagnostic = String::from_utf8_lossy(&diagnostic);
338
339	debug!(?status, len = %frame.len(), "Video thumbnail program exited.");
340
341	if !status.success() {
342		return Err!("Video thumbnail program failed with {status}: {diagnostic}");
343	}
344
345	if frame.is_empty() {
346		return Err!("Video thumbnail program produced no frame: {diagnostic}");
347	}
348
349	if u64::try_from(frame.len()).unwrap_or(u64::MAX) > limit {
350		return Err!("Video thumbnail program produced a frame past {limit} bytes.");
351	}
352
353	Ok(frame)
354}
355
356async fn drain<Pipe>(mut pipe: Pipe, limit: u64) -> Result<Vec<u8>, io::Error>
357where
358	Pipe: AsyncRead + Unpin + Send,
359{
360	let mut buf = Vec::new();
361
362	(&mut pipe)
363		.take(limit)
364		.read_to_end(&mut buf)
365		.await?;
366
367	// the pipe stays open past the limit: closing it signals a program that is
368	// still writing, turning an oversized frame or a chatty log into a kill
369	copy(&mut pipe, &mut sink()).await?;
370
371	Ok(buf)
372}
373
374#[cfg(unix)]
375fn kill_group(group: u32) {
376	let Ok(group) = i32::try_from(group) else {
377		return;
378	};
379
380	// SAFETY: the group holds only what this child spawned, and its identifier
381	// cannot be reused before the child is reaped, so the signal reaches that
382	// subtree alone.
383	unsafe {
384		killpg(group, SIGKILL);
385	}
386}
387
388#[cfg(not(unix))]
389fn kill_group(_group: u32) {}