mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-27 04:52:25 +03:00
feat: media bitrate control, HDR (#346)
Fixes: #335 Still a work-in-progress. --------- Co-authored-by: DatCaptainHorse <DatCaptainHorse@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Wanjohi <elviswanjohi47@gmail.com>
This commit is contained in:
co-authored by
DatCaptainHorse
Claude Opus 5
Wanjohi
parent
1c721962f4
commit
0811f57f1a
@@ -32,9 +32,9 @@ toml = "0.8"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
# Vulkan Video hardware encoding.
|
||||
pixelforge = { git = "https://github.com/DatCaptainHorse/pixelforge.git", rev = "681fa4dd8bce5dabf008d00983e991b0eb8b3696", features = ["dmabuf"] }
|
||||
pixelforge = { git = "https://github.com/DatCaptainHorse/pixelforge.git", rev = "b4b7b36af6f9cbd0fedae220915edd75de933743" }
|
||||
|
||||
# libc for DMA-BUF OS primitives
|
||||
# libc for socket options
|
||||
libc = "0.2"
|
||||
|
||||
# Shared IPC protocol
|
||||
|
||||
+84
-13
@@ -24,15 +24,12 @@ Game process
|
||||
│ vkCmdBindPipeline → detect HUD │
|
||||
│ vkQueuePresentKHR → capture+encode │
|
||||
└──────────────────────────────────────────────┘
|
||||
│ GPU blit, same device
|
||||
│ GPU blit on the game's queue, signals a timeline semaphore
|
||||
▼
|
||||
final_image (DMA-BUF exportable)
|
||||
│ get_dmabuf_fd(final_memory)
|
||||
ring slot (an image on the game's own device)
|
||||
│ read in place, once the blit's point is reached
|
||||
▼
|
||||
DmaBufImporter (pixelforge VkDevice)
|
||||
│ import_or_reuse() → vk::Image
|
||||
▼
|
||||
ColorConverter (GPU compute shader)
|
||||
ColorConverter (GPU compute shader, the encoder's own queue)
|
||||
│ BGRA/RGB10/FP16 → NV12/P010/YUV444
|
||||
▼
|
||||
Encoder (Vulkan Video: H.264 / H.265 / AV1)
|
||||
@@ -41,8 +38,24 @@ Encoder (Vulkan Video: H.264 / H.265 / AV1)
|
||||
Unix datagram → neshub → the client
|
||||
```
|
||||
|
||||
CPU fallback exists only for driver configurations without DMA-BUF external
|
||||
memory export.
|
||||
The encoder runs on the game's own `VkDevice`. The layer creates that device
|
||||
with what the encoder needs: the extensions and feature bits it asks for, and
|
||||
queues of its own wherever a queue family has one to spare, since a `VkQueue`
|
||||
may not be submitted to from two threads at once. Where no family has room,
|
||||
the game's queue is created internally synchronized and shared. Every step of
|
||||
a frame is ordered on the GPU; nothing waits on the CPU.
|
||||
|
||||
Where the conversion is only the YUV matrix and the device has
|
||||
`VK_VALVE_video_encode_rgb_conversion`, the encoder takes the RGB frame and
|
||||
converts it itself, and the converter is not built. That path runs at limited
|
||||
range, since the one driver offering it writes limited range whatever it is
|
||||
asked; everything else is full range.
|
||||
|
||||
When the game's device cannot host the encoder -- an instance the loader
|
||||
cannot raise to Vulkan 1.1, no queue the encoder could safely use, a driver
|
||||
refusing the additions, or `NESCAPTURE_SHARED_DEVICE=0` -- the encoder gets a
|
||||
device of its own, and each frame is read back on the CPU and uploaded there.
|
||||
That works everywhere and costs a copy each way.
|
||||
|
||||
---
|
||||
|
||||
@@ -93,11 +106,18 @@ implicit layer is loaded into *every* Vulkan process on the system.
|
||||
| `NESCAPTURE_CODEC` | best available | `h264`, `h265` or `av1`; probes if unset |
|
||||
| `NESCAPTURE_FORMAT` | `yuv420` | `yuv420` or `yuv444` |
|
||||
| `NESCAPTURE_DEPTH` | auto | `8` or `10`; inferred from the swapchain `VkFormat` if unset |
|
||||
| `NESCAPTURE_BITRATE` | `10000` | CBR target in kbps. Ignored when `NESCAPTURE_QP` is set |
|
||||
| `NESCAPTURE_QP` | _(unset)_ | Constant QP instead of CBR |
|
||||
| `NESCAPTURE_RC` | _(inferred)_ | `cqp`, `cbr` or `vbr`. Unset infers `cqp` when `NESCAPTURE_QP` is set, `cbr` otherwise |
|
||||
| `NESCAPTURE_BITRATE` | `10000` | Target bitrate in kbps, under `cbr` and `vbr` |
|
||||
| `NESCAPTURE_BITRATE_MAX` | 1.5x the target | VBR ceiling in kbps. Ignored outside `vbr` |
|
||||
| `NESCAPTURE_QP` | _(unset)_ | Constant QP, under `cqp` |
|
||||
| `NESCAPTURE_FPS` | `60` | Target frame rate |
|
||||
| `NESCAPTURE_IDR_INTERVAL` | `4` | Force an IDR every N **seconds** |
|
||||
| `NESCAPTURE_INTRA_REFRESH` | _(off)_ | Set to `1` to replace periodic key frames with an intra refresh cycle |
|
||||
| `NESCAPTURE_INTRA_REFRESH_QP_DELTA` | `-4` | QP shift inside the refresh band; negative spends bits on it |
|
||||
| `NESCAPTURE_INTRA_REFRESH_SHAPE` | auto | `rows`, `columns` or `partitions`; the driver chooses if unset |
|
||||
| `NESCAPTURE_TUNE` | _(unset)_ | `highquality`, `lowlatency`, `ultralowlatency`, `lossless` |
|
||||
| `NESCAPTURE_SHARED_DEVICE` | _(on)_ | Set to `0` to leave the game's device as the game asked for it, and encode on a device of the encoder's own with CPU readback |
|
||||
| `NESCAPTURE_RGB_ENCODE` | _(on)_ | Set to `0` to always convert with the shader, even where the encoder could convert RGB itself |
|
||||
| `NESCAPTURE_CONFIG` | _(unset)_ | Path to the per-app shader-hash TOML |
|
||||
| `NESCAPTURE_GAME_NAME` | exe basename | Override app identification for that config |
|
||||
| `NESCAPTURE_DISCOVER` | _(unset)_ | Set to `1` to log every draw, for finding HUD shaders |
|
||||
@@ -107,6 +127,57 @@ Everything else is decided at runtime: the client asks `neshub` for a codec or
|
||||
bitrate change and it arrives on the command socket, so the encoder is
|
||||
reconfigured without a restart.
|
||||
|
||||
### Intra refresh
|
||||
|
||||
Instead of a key frame every few seconds, each picture codes one slice of the
|
||||
image as intra, so after a full cycle every part has been refreshed. The same
|
||||
cost, paid evenly, with no picture much larger than any other — which is what
|
||||
a link with a latency budget wants, since a key frame is the largest frame
|
||||
there is.
|
||||
|
||||
**The cycle length is not configurable, deliberately.** It is bounded by how
|
||||
many refresh regions the picture actually has, and that depends on the codec's
|
||||
block size: at 1080p an H.265 picture is 17 CTB rows tall where an H.264 one is
|
||||
68 macroblock rows, so the same duration is comfortable for one codec and
|
||||
impossible for the other. The encoder knows the codec, the resolution and what
|
||||
the device allows, and derives it from the key frame interval it replaces.
|
||||
|
||||
The refresh here spreads cost only; it does not make the cycle a recovery
|
||||
point. Doing that would restrict prediction on every picture — expensive, and
|
||||
what turns the refreshed band into a visible discontinuity — to buy a
|
||||
guarantee this stream gets more cheaply from the client asking for an IDR.
|
||||
|
||||
The band is coded intra every cycle, so it carries none of the refinement its
|
||||
neighbours have accumulated and reads as a strip of lower quality sweeping
|
||||
across the picture. `NESCAPTURE_INTRA_REFRESH_QP_DELTA` spends bits back into
|
||||
it, out of the rest of the frame. Measured at 1080p with ColorVideoVDP, `-4`
|
||||
recovers a fifth of what intra refresh costs and the encoded size does not
|
||||
grow — but the best value depends on the content, and too large a shift
|
||||
starves the rest of the frame faster than too small a one helps. Devices that
|
||||
cannot express a negative delta, or whose refresh regions follow the slice
|
||||
layout rather than a block sweep, decline it and say so.
|
||||
|
||||
`NESCAPTURE_INTRA_REFRESH_SHAPE` stays configurable because the device cannot
|
||||
answer it: whether a horizontal or vertical sweep looks better depends on how
|
||||
the content moves. It also changes how many regions there are — a 1080p
|
||||
picture in 64×64 blocks is 17 rows but 30 columns, so `columns` allows a
|
||||
longer cycle and thus less intra per picture.
|
||||
|
||||
### Rate control
|
||||
|
||||
`cbr` holds every frame to the same size, which is what a link with a fixed
|
||||
budget wants. `vbr` holds the same *average* while letting a frame that needs
|
||||
it spend up to the ceiling — a scene change is coded rather than smeared, at
|
||||
the cost of a burst the path has to absorb. `cqp` holds quality constant and
|
||||
lets the bitrate go wherever the content takes it, which is a recording
|
||||
setting rather than a streaming one.
|
||||
|
||||
The command socket carries a target bitrate but has no way to name a mode
|
||||
beyond CBR and constant QP. A target arriving while the encode is `vbr` is
|
||||
therefore applied as a target, leaving the mode and the ceiling alone — so the
|
||||
ceiling asked for at launch survives a session, and a congestion controller
|
||||
adjusts underneath it. Retargeting costs no rebuild and no key frame.
|
||||
|
||||
---
|
||||
|
||||
## Per-app shader-hash config
|
||||
@@ -145,10 +216,10 @@ src/
|
||||
├── framebuffer.rs image view and framebuffer tracking
|
||||
├── commands.rs vkCmdBind*, vkCmdDraw*, vkCmdBeginRenderPass
|
||||
├── swapchain.rs vkCreateSwapchainKHR, image enumeration
|
||||
├── capture.rs GPU blit to the capture image, DMA-BUF export
|
||||
├── capture.rs GPU blit into the capture ring, CPU readback fallback
|
||||
├── present.rs vkQueuePresentKHR, encode dispatch
|
||||
├── encode.rs pixelforge pipeline, codec probing, IPC send
|
||||
├── dmabuf_import.rs cross-device zero-copy import
|
||||
├── shared.rs creating the game's device for the encoder to share
|
||||
├── config.rs per-app TOML shader-hash config
|
||||
└── discovery.rs draw-call logging for shader discovery
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# Runs a Vulkan workload under the compositor with the layer active, then checks
|
||||
# the encoded result against the compositor's own readback of the same frames.
|
||||
# Two independent paths see the same content: the compositor reads the surface
|
||||
# back to the CPU, the layer exports it as a DMA-BUF and encodes it on the GPU.
|
||||
# back to the CPU, the layer encodes it on the GPU without it leaving the device.
|
||||
# Agreement between them is the evidence; a single path cannot tell a correct
|
||||
# frame from a plausible-looking wrong one.
|
||||
#
|
||||
@@ -96,7 +96,8 @@ wait $RECV || true
|
||||
FRAMES="$(cat "$WORK/frames.txt")"
|
||||
echo
|
||||
echo "frames encoded: $FRAMES"
|
||||
grep -m1 "First import" "$WORK/run.log" || echo " (no DMA-BUF import logged)"
|
||||
grep -m1 "encoding on the game's own device" "$WORK/run.log" \
|
||||
|| echo " (encoding on a device of its own, with CPU readback)"
|
||||
|
||||
python3 - "$WORK" "$STREAM" "$FRAMES" <<'PY'
|
||||
import glob, subprocess, sys
|
||||
|
||||
+818
-287
File diff suppressed because it is too large
Load Diff
+163
-86
@@ -14,6 +14,7 @@ use std::os::raw::c_void;
|
||||
use std::sync::Arc;
|
||||
|
||||
const VK_LAYER_LINK_INFO: u32 = 0;
|
||||
const VK_LOADER_DATA_CALLBACK: u32 = 1;
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn vkCreateDevice(
|
||||
@@ -43,6 +44,14 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
};
|
||||
unsafe { (*layer_info).u.pDeviceLayerInfo = (*dev_link).pNext };
|
||||
|
||||
let set_loader_data = unsafe {
|
||||
find_layer_link::<VkLayerDeviceCreateInfo>(
|
||||
(*p_create_info).p_next as *const c_void,
|
||||
VK_LOADER_DATA_CALLBACK,
|
||||
)
|
||||
}
|
||||
.and_then(|info| unsafe { (*info).u.pfnSetDeviceLoaderData });
|
||||
|
||||
let inst_key = unsafe { dispatch_key(physical_device.as_raw() as *const c_void) };
|
||||
let istate = match INSTANCE_STATE.get(&inst_key) {
|
||||
Some(s) => s.clone(),
|
||||
@@ -54,11 +63,8 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
}
|
||||
};
|
||||
|
||||
// ── Inject DMA-BUF extensions for zero-copy capture ──────────────────
|
||||
let ci = unsafe { &*p_create_info };
|
||||
|
||||
// Collect the game's original extensions.
|
||||
let original_extensions: Vec<*const libc::c_char> =
|
||||
let game_extensions: Vec<*const libc::c_char> =
|
||||
if ci.enabled_extension_count > 0 && !ci.pp_enabled_extension_names.is_null() {
|
||||
unsafe {
|
||||
std::slice::from_raw_parts(
|
||||
@@ -71,69 +77,34 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Extensions we need — static byte strings so pointers stay valid.
|
||||
const EXT_EXTERNAL_MEMORY: &[u8] = b"VK_KHR_external_memory\0";
|
||||
const EXT_EXTERNAL_MEMORY_FD: &[u8] = b"VK_KHR_external_memory_fd\0";
|
||||
const EXT_EXTERNAL_MEMORY_DMABUF: &[u8] = b"VK_EXT_external_memory_dma_buf\0";
|
||||
// Lets the capture ring be allocated tiled. The importer has always created
|
||||
// its side with DRM_FORMAT_MODIFIER_EXT tiling; without this the producer
|
||||
// can only offer it a linear buffer.
|
||||
const EXT_IMAGE_DRM_FORMAT_MODIFIER: &[u8] = b"VK_EXT_image_drm_format_modifier\0";
|
||||
|
||||
let needed: &[&[u8]] = &[
|
||||
EXT_EXTERNAL_MEMORY,
|
||||
EXT_EXTERNAL_MEMORY_FD,
|
||||
EXT_EXTERNAL_MEMORY_DMABUF,
|
||||
EXT_IMAGE_DRM_FORMAT_MODIFIER,
|
||||
];
|
||||
|
||||
// Build extended list: original + any of ours not already present.
|
||||
let mut extended = original_extensions.clone();
|
||||
for &ext in needed {
|
||||
let name_cstr = unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked(ext) };
|
||||
let already = extended
|
||||
.iter()
|
||||
.any(|&ptr| unsafe { std::ffi::CStr::from_ptr(ptr) == name_cstr });
|
||||
if !already {
|
||||
extended.push(ext.as_ptr() as *const libc::c_char);
|
||||
// Where it can, the encoder runs on this very device, so the device is
|
||||
// first created with what that needs. Anything refused falls back to the
|
||||
// device exactly as the game asked for it, and the encoder to a device of
|
||||
// its own.
|
||||
let mut shared = None;
|
||||
if let Some(prepared) =
|
||||
unsafe { crate::shared::prepare(&istate, next_gdpa, physical_device, ci, &game_extensions) }
|
||||
{
|
||||
let shared_ci = prepared.create_info(ci);
|
||||
let result =
|
||||
unsafe { (istate.create_device)(physical_device, &shared_ci, p_allocator, p_device) };
|
||||
let (additions, entry, instance) = unsafe { prepared.finish() };
|
||||
if result == vk::Result::SUCCESS {
|
||||
shared = Some((additions, entry, instance));
|
||||
} else {
|
||||
log::warn!(
|
||||
"vkCreateDevice with the encoder's additions failed ({result:?}), \
|
||||
retrying as the game asked"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Try with injected extensions first.
|
||||
//
|
||||
// The device's queue create info is passed through unchanged. An earlier
|
||||
// version bumped the first family's queue count by one to get a dedicated
|
||||
// capture queue, which was then never used — and could not be: the capture
|
||||
// blit has to be submitted to the queue the game presents on, or it gains
|
||||
// no ordering against the present. All the bump did was risk exceeding the
|
||||
// family's available queue count on the way in.
|
||||
let mut modified_ci = *ci;
|
||||
modified_ci.enabled_extension_count = extended.len() as u32;
|
||||
modified_ci.pp_enabled_extension_names = extended.as_ptr();
|
||||
|
||||
let mut dmabuf_available = true;
|
||||
let result =
|
||||
unsafe { (istate.create_device)(physical_device, &modified_ci, p_allocator, p_device) };
|
||||
|
||||
let result = if result != vk::Result::SUCCESS {
|
||||
// Driver rejected our extensions — retry with original create info.
|
||||
log::warn!(
|
||||
"vkCreateDevice with DMA-BUF extensions failed ({:?}), \
|
||||
retrying without — CPU readback fallback will be used",
|
||||
result
|
||||
);
|
||||
dmabuf_available = false;
|
||||
unsafe { (istate.create_device)(physical_device, p_create_info, p_allocator, p_device) }
|
||||
} else {
|
||||
log::info!("DMA-BUF extensions injected successfully");
|
||||
result
|
||||
};
|
||||
if result != vk::Result::SUCCESS {
|
||||
return result;
|
||||
}
|
||||
|
||||
if !dmabuf_available {
|
||||
log::warn!("DMA-BUF extensions missing — will use CPU readback fallback (expensive!)");
|
||||
if shared.is_none() {
|
||||
let result = unsafe {
|
||||
(istate.create_device)(physical_device, p_create_info, p_allocator, p_device)
|
||||
};
|
||||
if result != vk::Result::SUCCESS {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
let device = unsafe { *p_device };
|
||||
@@ -156,6 +127,7 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
get_device_proc_addr: next_gdpa,
|
||||
destroy_device: load!(b"vkDestroyDevice\0"),
|
||||
get_device_queue: load!(b"vkGetDeviceQueue\0"),
|
||||
get_device_queue2: try_load!(b"vkGetDeviceQueue2\0"),
|
||||
queue_present_khr: try_load!(b"vkQueuePresentKHR\0"),
|
||||
|
||||
// Phase 1
|
||||
@@ -189,10 +161,6 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
cmd_pipeline_barrier: load!(b"vkCmdPipelineBarrier\0"),
|
||||
cmd_copy_image: load!(b"vkCmdCopyImage\0"),
|
||||
get_image_subresource_layout: load!(b"vkGetImageSubresourceLayout\0"),
|
||||
get_memory_fd_khr: try_load!(b"vkGetMemoryFdKHR\0"),
|
||||
get_image_drm_format_modifier_properties_ext: try_load!(
|
||||
b"vkGetImageDrmFormatModifierPropertiesEXT\0"
|
||||
),
|
||||
create_query_pool: try_load!(b"vkCreateQueryPool\0"),
|
||||
destroy_query_pool: try_load!(b"vkDestroyQueryPool\0"),
|
||||
cmd_reset_query_pool: try_load!(b"vkCmdResetQueryPool\0"),
|
||||
@@ -232,6 +200,17 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
|
||||
let key = unsafe { dispatch_key(device.as_raw() as *const c_void) };
|
||||
|
||||
let shared = shared.map(|(additions, entry, instance)| unsafe {
|
||||
crate::shared::SharedDevice::adopt(
|
||||
additions,
|
||||
entry,
|
||||
instance,
|
||||
physical_device,
|
||||
device,
|
||||
next_gdpa,
|
||||
)
|
||||
});
|
||||
|
||||
// Phase 3: load shader hash config
|
||||
let shader_hashes = config::resolve_config_path()
|
||||
.as_ref()
|
||||
@@ -252,6 +231,9 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
raw: device,
|
||||
physical_device,
|
||||
fp,
|
||||
shared,
|
||||
shared_active: std::sync::atomic::AtomicBool::new(false),
|
||||
set_loader_data,
|
||||
|
||||
shader_registry: DashMap::new(),
|
||||
pipeline_registry: DashMap::new(),
|
||||
@@ -279,6 +261,7 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
}),
|
||||
swapchain_colorspace: std::sync::atomic::AtomicU32::new(0),
|
||||
frame_counter: std::sync::atomic::AtomicU64::new(0),
|
||||
ring_generation: std::sync::atomic::AtomicU64::new(0),
|
||||
|
||||
hud_detected_frame: std::sync::atomic::AtomicBool::new(false),
|
||||
pending_capture_frame: std::sync::atomic::AtomicBool::new(false),
|
||||
@@ -287,7 +270,6 @@ pub unsafe extern "system" fn vkCreateDevice(
|
||||
|
||||
encoder: std::sync::Mutex::new(None),
|
||||
|
||||
|
||||
frame_gate: std::sync::Mutex::new(crate::pacing::FrameGate::from_env()),
|
||||
frame_pacer: std::sync::Mutex::new(crate::pacing::FramePacer::from_env()),
|
||||
last_present_return: std::sync::Mutex::new(None),
|
||||
@@ -317,23 +299,20 @@ pub unsafe extern "system" fn vkDestroyDevice(
|
||||
ds.pipeline_registry.len(),
|
||||
);
|
||||
|
||||
// ── 1. Shut down encoder pipeline (unblocks encoder + RTP threads) ───
|
||||
{
|
||||
let mut enc_guard = ds.encoder.lock().unwrap();
|
||||
if let Some(handle) = enc_guard.take() {
|
||||
handle.shutdown();
|
||||
// `handle` is dropped here → drops `frame_tx` → encoder thread's
|
||||
// recv_timeout returns Disconnected → encoder thread drops
|
||||
// `encoded_tx` → RTP thread exits too.
|
||||
//
|
||||
// Give threads a moment to drain. In production you'd join the
|
||||
// JoinHandles, but since we don't store them, a short sleep +
|
||||
// the AtomicBool shutdown flag is sufficient.
|
||||
log::info!("encoder pipeline shutdown signaled");
|
||||
// ── 1. Shut down the encode pipeline ──────────────────────────────────
|
||||
//
|
||||
// Waited for, not just signalled. On a shared device the encoder, its
|
||||
// converter and their images are objects on this very device, and
|
||||
// destroying the device under them is a use-after-free. The receive
|
||||
// timeout bounds how long the thread takes to notice.
|
||||
let handle = ds.encoder.lock().ok().and_then(|mut g| g.take());
|
||||
if let Some(handle) = handle {
|
||||
if handle.finish(std::time::Duration::from_secs(2)) {
|
||||
log::info!("encoder pipeline stopped");
|
||||
} else {
|
||||
log::error!("encoder thread did not stop in time; destroying the device anyway");
|
||||
}
|
||||
}
|
||||
// Brief yield to let threads notice the disconnect.
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
|
||||
// ── 2. Tear down the capture ring ─────────────────────────────────────
|
||||
//
|
||||
@@ -392,13 +371,111 @@ pub unsafe extern "system" fn vkGetDeviceQueue(
|
||||
) {
|
||||
let key = unsafe { dispatch_key(device.as_raw() as *const c_void) };
|
||||
if let Some(ds) = DEVICE_STATE.get(&key) {
|
||||
unsafe { (ds.fp.get_device_queue)(device, queue_family_index, queue_index, p_queue) };
|
||||
// A queue created internally synchronized, so the encoder can share
|
||||
// it, is invisible to vkGetDeviceQueue: only vkGetDeviceQueue2 with the
|
||||
// matching flags returns it. The game asked for a plain queue and gets
|
||||
// this one.
|
||||
match (
|
||||
shares_family(&ds, queue_family_index),
|
||||
ds.fp.get_device_queue2,
|
||||
) {
|
||||
(true, Some(get2)) => {
|
||||
let info = vk::DeviceQueueInfo2::default()
|
||||
.flags(vk::DeviceQueueCreateFlags::INTERNALLY_SYNCHRONIZED_KHR)
|
||||
.queue_family_index(queue_family_index)
|
||||
.queue_index(queue_index);
|
||||
unsafe { get2(device, &info, p_queue) };
|
||||
}
|
||||
_ => unsafe {
|
||||
(ds.fp.get_device_queue)(device, queue_family_index, queue_index, p_queue)
|
||||
},
|
||||
}
|
||||
let queue = unsafe { *p_queue };
|
||||
// The encoder's queues reach this hook without the loader in between.
|
||||
// For the game's, the loader stamps them again on the way out.
|
||||
unsafe { stamp(&ds, queue.as_raw() as *mut c_void) };
|
||||
QUEUE_TO_DEVICE_KEY.insert(queue.as_raw(), key);
|
||||
crate::state::QUEUE_TO_FAMILY.insert(queue.as_raw(), queue_family_index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Give a dispatchable object this layer obtained itself the loader's
|
||||
/// dispatch data.
|
||||
///
|
||||
/// The loader writes its dispatch pointer into every queue and command buffer
|
||||
/// that passes through its own entry points. One a layer allocates by calling
|
||||
/// the next layer directly never does, and a layer below this one that finds
|
||||
/// its per-object state by that pointer then finds nothing: the validation
|
||||
/// layer aborts, in the first vkCmd* recorded into such a command buffer. The
|
||||
/// loader hands every layer this callback at vkCreateDevice for exactly this.
|
||||
///
|
||||
/// # Safety
|
||||
///
|
||||
/// `object` must be a queue or command buffer of `ds`'s device.
|
||||
pub unsafe fn stamp(ds: &DeviceState, object: *mut c_void) {
|
||||
if let Some(set) = ds.set_loader_data
|
||||
&& unsafe { set(ds.raw, object) } != vk::Result::SUCCESS
|
||||
{
|
||||
log::warn!("vkSetDeviceLoaderData refused an object of the layer's own");
|
||||
}
|
||||
}
|
||||
|
||||
/// vkAllocateCommandBuffers for the encoder, whose command buffers are the
|
||||
/// layer's own: allocated below the loader, so stamped here. See [`stamp`].
|
||||
pub unsafe extern "system" fn encoder_allocate_command_buffers(
|
||||
device: vk::Device,
|
||||
p_allocate_info: *const vk::CommandBufferAllocateInfo<'_>,
|
||||
p_command_buffers: *mut vk::CommandBuffer,
|
||||
) -> vk::Result {
|
||||
let key = unsafe { dispatch_key(device.as_raw() as *const c_void) };
|
||||
let Some(ds) = DEVICE_STATE.get(&key).map(|s| s.clone()) else {
|
||||
return vk::Result::ERROR_INITIALIZATION_FAILED;
|
||||
};
|
||||
let result =
|
||||
unsafe { (ds.fp.allocate_command_buffers)(device, p_allocate_info, p_command_buffers) };
|
||||
if result == vk::Result::SUCCESS {
|
||||
let count = unsafe { (*p_allocate_info).command_buffer_count } as usize;
|
||||
for cb in unsafe { std::slice::from_raw_parts(p_command_buffers, count) } {
|
||||
unsafe { stamp(&ds, cb.as_raw() as *mut c_void) };
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Whether the game's queues in `family` were created internally synchronized
|
||||
/// for the encoder to share.
|
||||
fn shares_family(ds: &DeviceState, family: u32) -> bool {
|
||||
ds.shared
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.queues.internally_synchronized.contains(&family))
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn vkGetDeviceQueue2(
|
||||
device: vk::Device,
|
||||
p_queue_info: *const vk::DeviceQueueInfo2,
|
||||
p_queue: *mut vk::Queue,
|
||||
) {
|
||||
let key = unsafe { dispatch_key(device.as_raw() as *const c_void) };
|
||||
let Some(ds) = DEVICE_STATE.get(&key) else {
|
||||
return;
|
||||
};
|
||||
let Some(get2) = ds.fp.get_device_queue2 else {
|
||||
return;
|
||||
};
|
||||
// The flags have to match the ones the queue was created with, and for a
|
||||
// shared family the layer added one the game does not know about.
|
||||
let mut info = unsafe { *p_queue_info };
|
||||
if shares_family(&ds, info.queue_family_index) {
|
||||
info.flags |= vk::DeviceQueueCreateFlags::INTERNALLY_SYNCHRONIZED_KHR;
|
||||
}
|
||||
unsafe { get2(device, &info, p_queue) };
|
||||
let queue = unsafe { *p_queue };
|
||||
unsafe { stamp(&ds, queue.as_raw() as *mut c_void) };
|
||||
QUEUE_TO_DEVICE_KEY.insert(queue.as_raw(), key);
|
||||
crate::state::QUEUE_TO_FAMILY.insert(queue.as_raw(), info.queue_family_index);
|
||||
}
|
||||
|
||||
/// Enumerate device extensions supported by the physical device.
|
||||
unsafe fn enumerate_device_extensions(
|
||||
istate: &crate::dispatch::NextInstanceFn,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// discovery.rs — Phase 6: per-draw logging for shader hash discovery
|
||||
//
|
||||
// When HUDLESS_DISCOVER=1 is set, every vkCmdDraw* call is logged to:
|
||||
// /tmp/hudless_discover_$EXE.log
|
||||
// /tmp/nescapture_discover_$EXE.log
|
||||
//
|
||||
// Log format:
|
||||
// frame=0001 draw=00042 vert=0x1a2b3c4d5e6f7890 frag=0xaabbccddeeff0011 verts=6 blend=true depth=false
|
||||
@@ -40,7 +40,7 @@ fn ensure_log_file() -> bool {
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
});
|
||||
|
||||
let path = format!("/tmp/hudless_discover_{}.log", exe_name);
|
||||
let path = format!("/tmp/nescapture_discover_{}.log", exe_name);
|
||||
match OpenOptions::new().create(true).append(true).open(&path) {
|
||||
Ok(f) => {
|
||||
log::info!("discovery logging to {}", path);
|
||||
|
||||
@@ -38,6 +38,8 @@ pub type PFN_vkDestroyDevice =
|
||||
unsafe extern "system" fn(vk::Device, *const vk::AllocationCallbacks);
|
||||
|
||||
pub type PFN_vkGetDeviceQueue = unsafe extern "system" fn(vk::Device, u32, u32, *mut vk::Queue);
|
||||
pub type PFN_vkGetDeviceQueue2 =
|
||||
unsafe extern "system" fn(vk::Device, *const vk::DeviceQueueInfo2, *mut vk::Queue);
|
||||
|
||||
pub type PFN_vkQueuePresentKHR =
|
||||
unsafe extern "system" fn(vk::Queue, *const vk::PresentInfoKHR) -> vk::Result;
|
||||
@@ -190,20 +192,6 @@ pub type PFN_vkGetImageSubresourceLayout = unsafe extern "system" fn(
|
||||
*mut vk::SubresourceLayout,
|
||||
);
|
||||
|
||||
// DRM format modifiers. Both optional: without them the capture ring stays
|
||||
// linear, which is what it was before it could be anything else.
|
||||
pub type PFN_vkGetPhysicalDeviceFormatProperties2 = unsafe extern "system" fn(
|
||||
vk::PhysicalDevice,
|
||||
vk::Format,
|
||||
*mut vk::FormatProperties2<'_>,
|
||||
);
|
||||
|
||||
pub type PFN_vkGetImageDrmFormatModifierPropertiesEXT = unsafe extern "system" fn(
|
||||
vk::Device,
|
||||
vk::Image,
|
||||
*mut vk::ImageDrmFormatModifierPropertiesEXT<'_>,
|
||||
) -> vk::Result;
|
||||
|
||||
// Timestamp queries around the capture blit. All optional: without them the
|
||||
// blit's GPU cost is simply not reported.
|
||||
pub type PFN_vkGetPhysicalDeviceProperties =
|
||||
@@ -219,21 +207,14 @@ pub type PFN_vkCreateQueryPool = unsafe extern "system" fn(
|
||||
*mut vk::QueryPool,
|
||||
) -> vk::Result;
|
||||
|
||||
pub type PFN_vkDestroyQueryPool = unsafe extern "system" fn(
|
||||
vk::Device,
|
||||
vk::QueryPool,
|
||||
*const vk::AllocationCallbacks,
|
||||
);
|
||||
pub type PFN_vkDestroyQueryPool =
|
||||
unsafe extern "system" fn(vk::Device, vk::QueryPool, *const vk::AllocationCallbacks);
|
||||
|
||||
pub type PFN_vkCmdResetQueryPool =
|
||||
unsafe extern "system" fn(vk::CommandBuffer, vk::QueryPool, u32, u32);
|
||||
|
||||
pub type PFN_vkCmdWriteTimestamp = unsafe extern "system" fn(
|
||||
vk::CommandBuffer,
|
||||
vk::PipelineStageFlags,
|
||||
vk::QueryPool,
|
||||
u32,
|
||||
);
|
||||
pub type PFN_vkCmdWriteTimestamp =
|
||||
unsafe extern "system" fn(vk::CommandBuffer, vk::PipelineStageFlags, vk::QueryPool, u32);
|
||||
|
||||
pub type PFN_vkGetQueryPoolResults = unsafe extern "system" fn(
|
||||
vk::Device,
|
||||
@@ -246,13 +227,6 @@ pub type PFN_vkGetQueryPoolResults = unsafe extern "system" fn(
|
||||
vk::QueryResultFlags,
|
||||
) -> vk::Result;
|
||||
|
||||
// DMA-BUF fd export (used to share final_image with pixelforge zero-copy)
|
||||
pub type PFN_vkGetMemoryFdKHR = unsafe extern "system" fn(
|
||||
vk::Device,
|
||||
*const vk::MemoryGetFdInfoKHR,
|
||||
*mut std::os::raw::c_int,
|
||||
) -> vk::Result;
|
||||
|
||||
// ── Phase 4: Synchronisation ─────────────────────────────────────────────────
|
||||
|
||||
pub type PFN_vkCreateFence = unsafe extern "system" fn(
|
||||
@@ -366,13 +340,14 @@ pub type PFN_vkResetCommandBuffer =
|
||||
// ── Dispatch table structs ────────────────────────────────────────────────────
|
||||
|
||||
pub struct NextInstanceFn {
|
||||
/// The instance itself, for building the encoder's view of it.
|
||||
pub instance: vk::Instance,
|
||||
/// The Vulkan version the application asked for, which bounds what core
|
||||
/// functionality exists on its devices.
|
||||
pub api_version: u32,
|
||||
pub get_instance_proc_addr: PFN_vkGetInstanceProcAddr,
|
||||
pub destroy_instance: PFN_vkDestroyInstance,
|
||||
pub get_physical_device_memory_properties: PFN_vkGetPhysicalDeviceMemoryProperties,
|
||||
/// `None` on an instance below Vulkan 1.1 without
|
||||
/// `VK_KHR_get_physical_device_properties2`. Without it the modifier list
|
||||
/// cannot be queried and the capture ring stays linear.
|
||||
pub get_physical_device_format_properties2: Option<PFN_vkGetPhysicalDeviceFormatProperties2>,
|
||||
/// Needed for `timestampPeriod`, which turns device ticks into nanoseconds.
|
||||
pub get_physical_device_properties: Option<PFN_vkGetPhysicalDeviceProperties>,
|
||||
/// Needed for a queue family's `timestampValidBits`. A family reporting
|
||||
@@ -388,6 +363,9 @@ pub struct NextDeviceFn {
|
||||
pub get_device_proc_addr: PFN_vkGetDeviceProcAddr,
|
||||
pub destroy_device: PFN_vkDestroyDevice,
|
||||
pub get_device_queue: PFN_vkGetDeviceQueue,
|
||||
/// Core in 1.1. Needed to fetch queues created with flags, which is how a
|
||||
/// queue shared with the encoder is created.
|
||||
pub get_device_queue2: Option<PFN_vkGetDeviceQueue2>,
|
||||
pub queue_present_khr: Option<PFN_vkQueuePresentKHR>,
|
||||
|
||||
// Phase 1
|
||||
@@ -421,15 +399,6 @@ pub struct NextDeviceFn {
|
||||
pub cmd_pipeline_barrier: PFN_vkCmdPipelineBarrier,
|
||||
pub cmd_copy_image: PFN_vkCmdCopyImage,
|
||||
pub get_image_subresource_layout: PFN_vkGetImageSubresourceLayout,
|
||||
/// `None` when `VK_KHR_external_memory_fd` is unavailable.
|
||||
/// Required for DMA-BUF export to pixelforge's VkDevice.
|
||||
pub get_memory_fd_khr: Option<PFN_vkGetMemoryFdKHR>,
|
||||
/// `None` when `VK_EXT_image_drm_format_modifier` was not enabled. The
|
||||
/// driver picks the modifier from the list it is offered, so this is how
|
||||
/// the layer learns which one it actually got — and the importer needs the
|
||||
/// exact value, not the list.
|
||||
pub get_image_drm_format_modifier_properties_ext:
|
||||
Option<PFN_vkGetImageDrmFormatModifierPropertiesEXT>,
|
||||
|
||||
// Phase 4 — blit timing. All-or-nothing: the ring only times the blit when
|
||||
// every one of these loaded and the presenting queue family can timestamp.
|
||||
|
||||
@@ -1,259 +0,0 @@
|
||||
//! DMA-BUF import support for zero-copy video encoding.
|
||||
//!
|
||||
//! This module provides the ability to import Linux DMA-BUF file descriptors as
|
||||
//! Vulkan images for direct video encoding without CPU-side copies.
|
||||
//!
|
||||
//! `DmaBufImporter` caches imported Vulkan resources per compositor buffer index
|
||||
//! so that pre-allocated GBM buffers are imported only once. Subsequent frames
|
||||
//! from the same buffer reuse the cached `VkImage` and `VkDeviceMemory`,
|
||||
//! eliminating per-frame Vulkan object creation and layout transitions.
|
||||
|
||||
use anyhow::Result;
|
||||
use ash::vk;
|
||||
use log::debug;
|
||||
use pixelforge::VideoContext;
|
||||
use std::os::fd::RawFd;
|
||||
use std::os::unix::io::{BorrowedFd, IntoRawFd};
|
||||
|
||||
/// Information about a single DMA-BUF plane.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct DmaBufPlane {
|
||||
/// File descriptor for the DMA-BUF.
|
||||
pub fd: RawFd,
|
||||
/// Offset within the DMA-BUF to the start of this plane.
|
||||
pub offset: u32,
|
||||
/// Row stride in bytes.
|
||||
pub stride: u32,
|
||||
/// DRM format modifier.
|
||||
pub modifier: u64,
|
||||
}
|
||||
|
||||
/// Cached Vulkan resources for a single compositor buffer slot.
|
||||
struct CachedImport {
|
||||
image: vk::Image,
|
||||
memory: vk::DeviceMemory,
|
||||
}
|
||||
|
||||
/// Importer for DMA-BUF file descriptors into Vulkan images.
|
||||
///
|
||||
/// Owns a per-buffer-index cache of `VkImage` + `VkDeviceMemory`.
|
||||
/// Layout transitions are deferred to the consumer (e.g. `ColorConverter`)
|
||||
/// to avoid a separate GPU submission per first-time import.
|
||||
pub struct DmaBufImporter {
|
||||
context: VideoContext,
|
||||
external_memory_fd: ash::khr::external_memory_fd::Device,
|
||||
/// Per-buffer-index cache. Index corresponds to `ExportedFrame::buffer_index`.
|
||||
cached_imports: Vec<Option<CachedImport>>,
|
||||
}
|
||||
|
||||
impl DmaBufImporter {
|
||||
/// Create a new DMA-BUF importer.
|
||||
pub fn new(context: VideoContext) -> Result<Self> {
|
||||
let external_memory_fd =
|
||||
ash::khr::external_memory_fd::Device::load(context.instance(), context.device());
|
||||
|
||||
Ok(Self {
|
||||
context,
|
||||
external_memory_fd,
|
||||
cached_imports: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Import a DMA-BUF as a Vulkan image, reusing a cached import when
|
||||
/// the same `buffer_index` has been seen before.
|
||||
///
|
||||
/// The `format` parameter specifies the Vulkan format matching the DMA-BUF
|
||||
/// pixel format (e.g. `B8G8R8A8_UNORM` for SDR, `A2B10G10R10_UNORM_PACK32`
|
||||
/// for 10-bit HDR, `R16G16B16A16_SFLOAT` for FP16 HDR).
|
||||
///
|
||||
/// Returns `(image, needs_transition)` where `needs_transition` is `true`
|
||||
/// for first-time imports whose image is still in `UNDEFINED` layout.
|
||||
/// The caller is responsible for transitioning the image (e.g. by passing
|
||||
/// the appropriate `src_layout` to `ColorConverter::convert`).
|
||||
pub fn import_or_reuse(
|
||||
&mut self,
|
||||
buffer_index: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: vk::Format,
|
||||
planes: &[DmaBufPlane],
|
||||
) -> Result<(vk::Image, bool)> {
|
||||
// Grow the cache vector if needed.
|
||||
if self.cached_imports.len() <= buffer_index {
|
||||
self.cached_imports.resize_with(buffer_index + 1, || None);
|
||||
}
|
||||
|
||||
if let Some(cached) = &self.cached_imports[buffer_index] {
|
||||
return Ok((cached.image, false));
|
||||
}
|
||||
|
||||
// First time seeing this buffer — full import.
|
||||
debug!(
|
||||
"First import for buffer {buffer_index}: {}x{}, format={:?}, fd={}, stride={}, modifier={:#x}",
|
||||
width, height, format, planes[0].fd, planes[0].stride, planes[0].modifier
|
||||
);
|
||||
|
||||
let (image, memory) = self.import_internal(width, height, format, planes)?;
|
||||
|
||||
self.cached_imports[buffer_index] = Some(CachedImport { image, memory });
|
||||
Ok((image, true))
|
||||
}
|
||||
|
||||
/// Perform the raw Vulkan import of a DMA-BUF with the specified format.
|
||||
///
|
||||
/// Returns the `(VkImage, VkDeviceMemory)` pair. The image is in
|
||||
/// `UNDEFINED` layout; the caller must transition it.
|
||||
fn import_internal(
|
||||
&self,
|
||||
width: u32,
|
||||
height: u32,
|
||||
format: vk::Format,
|
||||
planes: &[DmaBufPlane],
|
||||
) -> Result<(vk::Image, vk::DeviceMemory)> {
|
||||
if planes.is_empty() {
|
||||
return Err(anyhow::anyhow!("At least one DMA-BUF plane is required"));
|
||||
}
|
||||
|
||||
let device = self.context.device();
|
||||
|
||||
// Build DRM format modifier plane layouts for all planes.
|
||||
// AMD modifiers (e.g. tiled/DCC) may require multiple planes;
|
||||
// the layout count must match the modifier's expected plane count.
|
||||
let plane_layouts: Vec<vk::SubresourceLayout> = planes
|
||||
.iter()
|
||||
.map(|p| {
|
||||
vk::SubresourceLayout::default()
|
||||
.offset(p.offset as u64)
|
||||
.row_pitch(p.stride as u64)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let modifier = planes[0].modifier;
|
||||
let mut drm_format_modifier_info =
|
||||
vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
|
||||
.drm_format_modifier(modifier)
|
||||
.plane_layouts(&plane_layouts);
|
||||
|
||||
let mut external_memory_info = vk::ExternalMemoryImageCreateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||
external_memory_info.p_next = &mut drm_format_modifier_info
|
||||
as *mut vk::ImageDrmFormatModifierExplicitCreateInfoEXT
|
||||
as *mut _;
|
||||
|
||||
let mut image_create_info = vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(format)
|
||||
.extent(vk::Extent3D {
|
||||
width,
|
||||
height,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::DRM_FORMAT_MODIFIER_EXT)
|
||||
.usage(vk::ImageUsageFlags::TRANSFER_SRC | vk::ImageUsageFlags::SAMPLED)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED);
|
||||
image_create_info.p_next =
|
||||
&mut external_memory_info as *mut vk::ExternalMemoryImageCreateInfo as *mut _;
|
||||
|
||||
let image = unsafe { device.create_image(&image_create_info, None) }
|
||||
.map_err(|e| anyhow::anyhow!("DMA-BUF image creation: {e}"))?;
|
||||
|
||||
// Memory requirements.
|
||||
let mem_requirements = unsafe { device.get_image_memory_requirements(image) };
|
||||
|
||||
// FD memory properties.
|
||||
let mut memory_fd_properties = vk::MemoryFdPropertiesKHR::default();
|
||||
unsafe {
|
||||
self.external_memory_fd.get_memory_fd_properties(
|
||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
planes[0].fd,
|
||||
&mut memory_fd_properties,
|
||||
)
|
||||
}
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get memory FD properties: {e}"))?;
|
||||
|
||||
// Duplicate the FD — vkAllocateMemory consumes it.
|
||||
let fd = unsafe { BorrowedFd::borrow_raw(planes[0].fd) }
|
||||
.try_clone_to_owned()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to duplicate DMA-BUF FD: {e}"))?
|
||||
.into_raw_fd();
|
||||
|
||||
let mut import_memory_fd_info = vk::ImportMemoryFdInfoKHR::default()
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
||||
.fd(fd);
|
||||
|
||||
let memory_type_bits =
|
||||
mem_requirements.memory_type_bits & memory_fd_properties.memory_type_bits;
|
||||
|
||||
debug!(
|
||||
"Memory allocation: size={}, image_type_bits={:#x}, fd_type_bits={:#x}, combined={:#x}",
|
||||
mem_requirements.size,
|
||||
mem_requirements.memory_type_bits,
|
||||
memory_fd_properties.memory_type_bits,
|
||||
memory_type_bits
|
||||
);
|
||||
|
||||
let memory_type_index = self
|
||||
.context
|
||||
.find_memory_type(memory_type_bits, vk::MemoryPropertyFlags::empty())
|
||||
.ok_or_else(|| {
|
||||
// The numbers, in the error rather than in the debug! above
|
||||
// it: fd_type_bits=0 means the driver could not resolve the
|
||||
// descriptor at all, which is a different fault from a
|
||||
// mismatch, and the difference is the whole diagnosis.
|
||||
anyhow::anyhow!(
|
||||
"No suitable memory type for DMA-BUF import: \
|
||||
image_type_bits={:#x} & fd_type_bits={:#x} = {:#x}, \
|
||||
size={}, format={:?}, modifier={:#x}, stride={}",
|
||||
mem_requirements.memory_type_bits,
|
||||
memory_fd_properties.memory_type_bits,
|
||||
memory_type_bits,
|
||||
mem_requirements.size,
|
||||
format,
|
||||
planes[0].modifier,
|
||||
planes[0].stride
|
||||
)
|
||||
})?;
|
||||
|
||||
// Dedicated allocation (required by many drivers for external memory).
|
||||
let mut dedicated_alloc_info = vk::MemoryDedicatedAllocateInfo::default().image(image);
|
||||
import_memory_fd_info.p_next =
|
||||
&mut dedicated_alloc_info as *mut vk::MemoryDedicatedAllocateInfo as *mut _;
|
||||
|
||||
let mut alloc_info = vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(mem_requirements.size)
|
||||
.memory_type_index(memory_type_index);
|
||||
alloc_info.p_next = &mut import_memory_fd_info as *mut vk::ImportMemoryFdInfoKHR as *mut _;
|
||||
|
||||
let memory = unsafe { device.allocate_memory(&alloc_info, None) }.map_err(|e| {
|
||||
unsafe { device.destroy_image(image, None) };
|
||||
anyhow::anyhow!("DMA-BUF memory import: {e}")
|
||||
})?;
|
||||
|
||||
if let Err(e) = unsafe { device.bind_image_memory(image, memory, 0) } {
|
||||
unsafe {
|
||||
device.free_memory(memory, None);
|
||||
device.destroy_image(image, None);
|
||||
}
|
||||
return Err(anyhow::anyhow!("DMA-BUF memory bind: {e}"));
|
||||
}
|
||||
|
||||
Ok((image, memory))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DmaBufImporter {
|
||||
fn drop(&mut self) {
|
||||
let device = self.context.device();
|
||||
unsafe {
|
||||
// Clean up cached imports.
|
||||
for cached in self.cached_imports.drain(..).flatten() {
|
||||
device.destroy_image(cached.image, None);
|
||||
device.free_memory(cached.memory, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2604
-356
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,42 @@ pub unsafe extern "system" fn vkCreateInstance(
|
||||
let next_create: PFN_vkCreateInstance =
|
||||
unsafe { load_instance_fn(next_gipa, vk::Instance::null(), b"vkCreateInstance\0") };
|
||||
|
||||
let result = unsafe { next_create(p_create_info, p_allocator, p_instance) };
|
||||
// The encoder needs Vulkan 1.1 on the game's instance to run on the game's
|
||||
// device. An application asking for 1.0 is raised to 1.1 where the loader
|
||||
// has it: 1.1 only adds to 1.0, so nothing the application can do behaves
|
||||
// any differently, and without it the encoder needs a device of its own.
|
||||
let asked = unsafe {
|
||||
let app = (*p_create_info).p_application_info;
|
||||
if app.is_null() {
|
||||
vk::API_VERSION_1_0
|
||||
} else {
|
||||
(*app).api_version
|
||||
}
|
||||
};
|
||||
let mut raised_app;
|
||||
let mut raised_ci;
|
||||
let mut create_info = p_create_info;
|
||||
let mut api_version = asked;
|
||||
if asked < vk::API_VERSION_1_1 && unsafe { loader_version(next_gipa) } >= vk::API_VERSION_1_1 {
|
||||
raised_app = unsafe {
|
||||
let app = (*p_create_info).p_application_info;
|
||||
if app.is_null() {
|
||||
vk::ApplicationInfo::default()
|
||||
} else {
|
||||
*app
|
||||
}
|
||||
};
|
||||
raised_app.api_version = vk::API_VERSION_1_1;
|
||||
raised_ci = unsafe { *p_create_info };
|
||||
raised_ci.p_application_info = &raised_app;
|
||||
create_info = &raised_ci;
|
||||
api_version = vk::API_VERSION_1_1;
|
||||
log::info!(
|
||||
"instance asked for Vulkan 1.0; created as 1.1 so the encoder can share its devices"
|
||||
);
|
||||
}
|
||||
|
||||
let result = unsafe { next_create(create_info, p_allocator, p_instance) };
|
||||
if result != vk::Result::SUCCESS {
|
||||
return result;
|
||||
}
|
||||
@@ -51,6 +86,8 @@ pub unsafe extern "system" fn vkCreateInstance(
|
||||
|
||||
// ── Build and store per-instance dispatch table ───────────────────────────
|
||||
let istate = Arc::new(NextInstanceFn {
|
||||
instance,
|
||||
api_version,
|
||||
get_instance_proc_addr: next_gipa,
|
||||
destroy_instance: unsafe { load_instance_fn(next_gipa, instance, b"vkDestroyInstance\0") },
|
||||
get_physical_device_memory_properties: unsafe {
|
||||
@@ -60,13 +97,6 @@ pub unsafe extern "system" fn vkCreateInstance(
|
||||
b"vkGetPhysicalDeviceMemoryProperties\0",
|
||||
)
|
||||
},
|
||||
get_physical_device_format_properties2: unsafe {
|
||||
crate::try_load_instance_fn(
|
||||
next_gipa,
|
||||
instance,
|
||||
b"vkGetPhysicalDeviceFormatProperties2\0",
|
||||
)
|
||||
},
|
||||
get_physical_device_properties: unsafe {
|
||||
crate::try_load_instance_fn(next_gipa, instance, b"vkGetPhysicalDeviceProperties\0")
|
||||
},
|
||||
@@ -85,6 +115,26 @@ pub unsafe extern "system" fn vkCreateInstance(
|
||||
vk::Result::SUCCESS
|
||||
}
|
||||
|
||||
/// The highest instance version the loader below supports; 1.0 when it cannot
|
||||
/// say, since vkEnumerateInstanceVersion is itself a 1.1 addition.
|
||||
unsafe fn loader_version(next_gipa: crate::dispatch::PFN_vkGetInstanceProcAddr) -> u32 {
|
||||
type Enumerate = unsafe extern "system" fn(*mut u32) -> vk::Result;
|
||||
let Some(f) = (unsafe {
|
||||
crate::try_load_instance_fn::<Enumerate>(
|
||||
next_gipa,
|
||||
vk::Instance::null(),
|
||||
b"vkEnumerateInstanceVersion\0",
|
||||
)
|
||||
}) else {
|
||||
return vk::API_VERSION_1_0;
|
||||
};
|
||||
let mut version = vk::API_VERSION_1_0;
|
||||
if unsafe { f(&mut version) } != vk::Result::SUCCESS {
|
||||
return vk::API_VERSION_1_0;
|
||||
}
|
||||
version
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn vkDestroyInstance(
|
||||
instance: vk::Instance,
|
||||
|
||||
@@ -27,20 +27,20 @@ mod config;
|
||||
mod device;
|
||||
mod discovery;
|
||||
mod dispatch;
|
||||
mod dmabuf_import;
|
||||
mod encode;
|
||||
mod framebuffer;
|
||||
mod instance;
|
||||
mod memory;
|
||||
mod modifiers;
|
||||
mod pacing;
|
||||
mod pipeline;
|
||||
mod present;
|
||||
mod rate_probe;
|
||||
mod shader;
|
||||
mod shared;
|
||||
mod slots;
|
||||
mod state;
|
||||
mod timing;
|
||||
mod swapchain;
|
||||
mod timing;
|
||||
|
||||
use commands::{
|
||||
vkCmdBeginRenderPass, vkCmdBeginRenderingKHR, vkCmdBindPipeline, vkCmdDraw, vkCmdDrawIndexed,
|
||||
@@ -48,7 +48,7 @@ use commands::{
|
||||
vkCmdDrawIndirect, vkCmdDrawIndirectCount, vkCmdDrawIndirectCountKHR, vkCmdEndRenderPass,
|
||||
vkCmdEndRenderingKHR,
|
||||
};
|
||||
use device::{vkCreateDevice, vkDestroyDevice, vkGetDeviceQueue};
|
||||
use device::{vkCreateDevice, vkDestroyDevice, vkGetDeviceQueue, vkGetDeviceQueue2};
|
||||
use framebuffer::{
|
||||
vkAllocateCommandBuffers, vkCreateFramebuffer, vkCreateImageView, vkDestroyFramebuffer,
|
||||
vkDestroyImageView, vkFreeCommandBuffers,
|
||||
@@ -100,10 +100,16 @@ pub(crate) struct VkLayerDeviceLink {
|
||||
pfnNextGetDeviceProcAddr: Option<PFN_vkGetDeviceProcAddr>,
|
||||
}
|
||||
|
||||
/// Stamps the loader's dispatch data into a dispatchable object a layer
|
||||
/// created itself. See [`crate::device::stamp`].
|
||||
pub(crate) type PFN_vkSetDeviceLoaderData =
|
||||
unsafe extern "system" fn(vk::Device, *mut c_void) -> vk::Result;
|
||||
|
||||
#[repr(C)]
|
||||
pub(crate) union VkLayerCreateInfoU {
|
||||
pub pLayerInfo: *mut VkLayerInstanceLink,
|
||||
pub pDeviceLayerInfo: *mut VkLayerDeviceLink,
|
||||
pub pfnSetDeviceLoaderData: Option<PFN_vkSetDeviceLoaderData>,
|
||||
}
|
||||
|
||||
const VK_STRUCTURE_TYPE_LOADER_INSTANCE_CREATE_INFO: i32 = 47;
|
||||
@@ -271,6 +277,7 @@ unsafe fn match_device_fn(name: &[u8]) -> Option<RawFn> {
|
||||
b"vkCreateDevice" => Some(to_raw(vkCreateDevice as *const () as usize)),
|
||||
b"vkDestroyDevice" => Some(to_raw(vkDestroyDevice as *const () as usize)),
|
||||
b"vkGetDeviceQueue" => Some(to_raw(vkGetDeviceQueue as *const () as usize)),
|
||||
b"vkGetDeviceQueue2" => Some(to_raw(vkGetDeviceQueue2 as *const () as usize)),
|
||||
b"vkQueuePresentKHR" => Some(to_raw(vkQueuePresentKHR as *const () as usize)),
|
||||
|
||||
b"vkCreateShaderModule" => Some(to_raw(vkCreateShaderModule as *const () as usize)),
|
||||
|
||||
@@ -43,7 +43,12 @@ pub fn pick_memory_type(types: &[MemoryType], bits: u32, want: Want) -> Option<u
|
||||
|
||||
match want {
|
||||
Want::DeviceLocal => (0..types.len())
|
||||
.find(|&i| allowed(i) && types[i].flags.contains(vk::MemoryPropertyFlags::DEVICE_LOCAL))
|
||||
.find(|&i| {
|
||||
allowed(i)
|
||||
&& types[i]
|
||||
.flags
|
||||
.contains(vk::MemoryPropertyFlags::DEVICE_LOCAL)
|
||||
})
|
||||
.or_else(|| (0..types.len()).find(|&i| allowed(i)))
|
||||
.map(|i| i as u32),
|
||||
Want::HostCoherent => (0..types.len())
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// modifiers.rs — choosing a DRM format modifier for the capture ring
|
||||
//
|
||||
// The importer has always been able to take a tiled buffer: `dmabuf_import.rs`
|
||||
// builds `VkImageDrmFormatModifierExplicitCreateInfoEXT` with per-plane
|
||||
// layouts and creates the image with `DRM_FORMAT_MODIFIER_EXT` tiling. Only
|
||||
// the producer was linear — hard-coded `ImageTiling::LINEAR` and a `modifier`
|
||||
// of zero passed down with every frame — so every capture detiled a full frame
|
||||
// on the write and the encoder sampled a linear image on the read.
|
||||
//
|
||||
// Picking the modifier is the whole of the decision and it is pure, so it is
|
||||
// here and tested rather than buried in an unsafe block.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// One entry of `VkDrmFormatModifierPropertiesListEXT`, already filtered to
|
||||
/// modifiers whose tiling features cover both our write and the encoder's read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ModifierProps {
|
||||
pub modifier: u64,
|
||||
pub plane_count: u32,
|
||||
}
|
||||
|
||||
/// `DRM_FORMAT_MOD_LINEAR`.
|
||||
pub const LINEAR: u64 = 0;
|
||||
|
||||
/// `DRM_FORMAT_MOD_INVALID`, which a driver may list and which means "let the
|
||||
/// driver choose" — not something to ask for explicitly.
|
||||
pub const INVALID: u64 = 0x00ff_ffff_ffff_ffff;
|
||||
|
||||
/// Pick the modifier to allocate the capture ring with.
|
||||
///
|
||||
/// Single-plane only, and deliberately so. A multi-plane modifier needs an
|
||||
/// offset and a stride per plane on the import side, and the export path hands
|
||||
/// out one fd with one stride — so accepting one would produce an image the far
|
||||
/// side reads at the wrong offsets, which arrives at the right size and frame
|
||||
/// rate carrying nonsense. Prefer any real tiled modifier; fall back to linear,
|
||||
/// which is what the ring used before this existed and always works.
|
||||
pub fn pick_modifier(candidates: &[ModifierProps]) -> Option<ModifierProps> {
|
||||
let usable = |m: &&ModifierProps| m.plane_count == 1 && m.modifier != INVALID;
|
||||
candidates
|
||||
.iter()
|
||||
.filter(usable)
|
||||
.find(|m| m.modifier != LINEAR)
|
||||
.or_else(|| candidates.iter().filter(usable).find(|m| m.modifier == LINEAR))
|
||||
.copied()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TILED: u64 = 0x0200_0000_0000_0001;
|
||||
|
||||
#[test]
|
||||
fn a_tiled_modifier_beats_linear() {
|
||||
let c = [
|
||||
ModifierProps { modifier: LINEAR, plane_count: 1 },
|
||||
ModifierProps { modifier: TILED, plane_count: 1 },
|
||||
];
|
||||
assert_eq!(pick_modifier(&c).unwrap().modifier, TILED);
|
||||
}
|
||||
|
||||
/// Order must not decide it — the driver lists them in its own order.
|
||||
#[test]
|
||||
fn a_tiled_modifier_wins_from_either_position() {
|
||||
let c = [
|
||||
ModifierProps { modifier: TILED, plane_count: 1 },
|
||||
ModifierProps { modifier: LINEAR, plane_count: 1 },
|
||||
];
|
||||
assert_eq!(pick_modifier(&c).unwrap().modifier, TILED);
|
||||
}
|
||||
|
||||
/// A multi-plane modifier with one exported fd would be imported at the
|
||||
/// wrong plane offsets and produce a corrupt frame rather than an error.
|
||||
#[test]
|
||||
fn multi_plane_modifiers_are_refused() {
|
||||
let c = [
|
||||
ModifierProps { modifier: 0x0200_0000_0000_0002, plane_count: 2 },
|
||||
ModifierProps { modifier: LINEAR, plane_count: 1 },
|
||||
];
|
||||
assert_eq!(pick_modifier(&c).unwrap().modifier, LINEAR);
|
||||
}
|
||||
|
||||
/// A multi-plane tiled modifier must not beat a single-plane linear one
|
||||
/// just for being tiled.
|
||||
#[test]
|
||||
fn tiling_does_not_excuse_a_plane_count_we_cannot_export() {
|
||||
let c = [
|
||||
ModifierProps { modifier: TILED, plane_count: 4 },
|
||||
ModifierProps { modifier: LINEAR, plane_count: 1 },
|
||||
];
|
||||
assert_eq!(pick_modifier(&c).unwrap().modifier, LINEAR);
|
||||
}
|
||||
|
||||
/// `DRM_FORMAT_MOD_INVALID` is not a modifier to ask for.
|
||||
#[test]
|
||||
fn the_invalid_modifier_is_never_chosen() {
|
||||
let c = [ModifierProps { modifier: INVALID, plane_count: 1 }];
|
||||
assert_eq!(pick_modifier(&c), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_usable_is_none() {
|
||||
let c = [ModifierProps { modifier: TILED, plane_count: 4 }];
|
||||
assert_eq!(pick_modifier(&c), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_is_none() {
|
||||
assert_eq!(pick_modifier(&[]), None);
|
||||
}
|
||||
}
|
||||
+118
-40
@@ -1,5 +1,5 @@
|
||||
use crate::capture;
|
||||
use crate::encode::{CapturedFrame, FrameSource, PipelineConfig, PipelineHandle};
|
||||
use crate::encode::{CapturedFrame, FrameSource, PipelineConfig, PipelineHandle, PresentStep};
|
||||
use crate::slots::SlotGuard;
|
||||
use crate::state::{DEVICE_STATE, QUEUE_TO_DEVICE_KEY};
|
||||
use ash::vk::{self, Handle};
|
||||
@@ -32,6 +32,7 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
|
||||
// it is the game's own frame time with this layer's cost excluded — the
|
||||
// three spans then partition the wall clock between presents exactly.
|
||||
let entered = std::time::Instant::now();
|
||||
note_present(&ds, PresentStep::InHook);
|
||||
|
||||
ds.frame_counter.fetch_add(1, Ordering::Relaxed);
|
||||
ds.hud_detected_frame.store(false, Ordering::Relaxed);
|
||||
@@ -55,9 +56,8 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
|
||||
// Rewriting the wait semaphores is only well defined for a single
|
||||
// swapchain. A multi-swapchain present is rare enough that passing it
|
||||
// through untouched beats getting the interposition subtly wrong.
|
||||
let single_swapchain = pi.swapchain_count == 1
|
||||
&& !pi.p_swapchains.is_null()
|
||||
&& !pi.p_image_indices.is_null();
|
||||
let single_swapchain =
|
||||
pi.swapchain_count == 1 && !pi.p_swapchains.is_null() && !pi.p_image_indices.is_null();
|
||||
|
||||
let submission = if single_swapchain {
|
||||
unsafe { try_capture(&ds, queue, pi) }
|
||||
@@ -68,9 +68,11 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
|
||||
let down_us = std::cell::Cell::new(std::time::Duration::ZERO);
|
||||
let call_down = |info: *const vk::PresentInfoKHR| match ds.fp.queue_present_khr {
|
||||
Some(f) => {
|
||||
note_present(&ds, PresentStep::Presenting);
|
||||
let t = std::time::Instant::now();
|
||||
let r = unsafe { f(queue, info) };
|
||||
down_us.set(t.elapsed());
|
||||
note_present(&ds, PresentStep::InHook);
|
||||
r
|
||||
}
|
||||
None => vk::Result::ERROR_EXTENSION_NOT_PRESENT,
|
||||
@@ -82,7 +84,6 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
|
||||
return r;
|
||||
};
|
||||
|
||||
|
||||
// The blit consumed the application's wait semaphores, so the present waits
|
||||
// on ours instead. Presenting on the originals as well would be a second
|
||||
// wait on an already-consumed signal.
|
||||
@@ -121,11 +122,7 @@ pub unsafe extern "system" fn vkQueuePresentKHR(
|
||||
/// `layer` is everything in this hook that is not the down-call, both sides of
|
||||
/// it added together, so `gap + layer + down` accounts for the wall clock
|
||||
/// between one present and the next with nothing unattributed.
|
||||
fn finish(
|
||||
ds: &crate::state::DeviceState,
|
||||
entered: std::time::Instant,
|
||||
down: std::time::Duration,
|
||||
) {
|
||||
fn finish(ds: &crate::state::DeviceState, entered: std::time::Instant, down: std::time::Duration) {
|
||||
// Everything this hook cost, before any deliberate waiting.
|
||||
let worked = std::time::Instant::now();
|
||||
|
||||
@@ -138,6 +135,7 @@ fn finish(
|
||||
Err(_) => std::time::Duration::ZERO,
|
||||
};
|
||||
if !held.is_zero() {
|
||||
note_present(ds, PresentStep::Holding);
|
||||
std::thread::sleep(held);
|
||||
}
|
||||
|
||||
@@ -167,12 +165,24 @@ fn finish(
|
||||
if let Ok(mut last) = ds.last_present_return.lock() {
|
||||
*last = Some(now);
|
||||
}
|
||||
note_present(ds, PresentStep::InGame);
|
||||
}
|
||||
|
||||
/// Tell the stall watchdog where the game's present thread is. A no-op until
|
||||
/// the pipeline exists.
|
||||
pub fn note_present(ds: &crate::state::DeviceState, step: PresentStep) {
|
||||
if let Ok(enc) = ds.encoder.lock()
|
||||
&& let Some(ref h) = *enc
|
||||
{
|
||||
h.note_present(step);
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the present hook needs to carry from the blit to the worker.
|
||||
struct Submission {
|
||||
slot: SlotGuard,
|
||||
present_wait: vk::Semaphore,
|
||||
blit: Option<pixelforge::TimelinePoint>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
sc_fmt: vk::Format,
|
||||
@@ -185,6 +195,13 @@ unsafe fn try_capture(
|
||||
pi: &vk::PresentInfoKHR,
|
||||
) -> Option<Submission> {
|
||||
let image_index = unsafe { *pi.p_image_indices } as usize;
|
||||
// An image acquired from a retired swapchain may still be presented, and
|
||||
// the tracked images belong to its replacement: the same index there is a
|
||||
// different image, possibly of a different size.
|
||||
let presented = unsafe { *pi.p_swapchains };
|
||||
if !crate::swapchain::is_current(*ds.swapchain.lock().ok()?, presented) {
|
||||
return None;
|
||||
}
|
||||
let (sc_image, sc_fmt, sc_ext, image_count) = {
|
||||
let images = ds.swapchain_images.lock().ok()?;
|
||||
let fmt = *ds.swapchain_format.lock().ok()?;
|
||||
@@ -196,7 +213,7 @@ unsafe fn try_capture(
|
||||
};
|
||||
|
||||
// Gate before any GPU work is queued. A game presenting faster than the
|
||||
// target would otherwise pay a full blit and DMA-BUF export for frames the
|
||||
// target would otherwise pay a full blit for frames the
|
||||
// encoder throws away moments later.
|
||||
let present_time = std::time::Instant::now();
|
||||
let admitted = match ds.frame_gate.lock() {
|
||||
@@ -221,14 +238,14 @@ unsafe fn try_capture(
|
||||
}
|
||||
}
|
||||
|
||||
let app_waits: &[vk::Semaphore] = if pi.wait_semaphore_count == 0 || pi.p_wait_semaphores.is_null()
|
||||
{
|
||||
&[]
|
||||
} else {
|
||||
unsafe {
|
||||
std::slice::from_raw_parts(pi.p_wait_semaphores, pi.wait_semaphore_count as usize)
|
||||
}
|
||||
};
|
||||
let app_waits: &[vk::Semaphore] =
|
||||
if pi.wait_semaphore_count == 0 || pi.p_wait_semaphores.is_null() {
|
||||
&[]
|
||||
} else {
|
||||
unsafe {
|
||||
std::slice::from_raw_parts(pi.p_wait_semaphores, pi.wait_semaphore_count as usize)
|
||||
}
|
||||
};
|
||||
|
||||
let submission = unsafe {
|
||||
capture::capture_present_frame(
|
||||
@@ -246,6 +263,7 @@ unsafe fn try_capture(
|
||||
Some(Submission {
|
||||
slot: submission.slot,
|
||||
present_wait: submission.present_wait,
|
||||
blit: submission.blit,
|
||||
width: sc_ext.width,
|
||||
height: sc_ext.height,
|
||||
sc_fmt,
|
||||
@@ -273,6 +291,7 @@ fn queue_for_encode(ds: &crate::state::DeviceState, submission: Submission) {
|
||||
vk_colorspace: ds.swapchain_colorspace.load(Ordering::Relaxed),
|
||||
present_time: submission.present_time,
|
||||
slot: Some(submission.slot),
|
||||
blit: submission.blit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,11 +322,31 @@ fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, heig
|
||||
log::error!("no encode pipeline configuration; capture disabled");
|
||||
return;
|
||||
};
|
||||
match PipelineHandle::new(cfg) {
|
||||
// On the game's own device where it was created for that, and
|
||||
// on a device of the encoder's own where it was not, or where
|
||||
// adopting it fails.
|
||||
let shared = ds.shared.as_ref().and_then(|s| match s.video_context() {
|
||||
Ok(ctx) => Some(ctx),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"could not encode on the game's device ({e}); using a device of its own"
|
||||
);
|
||||
None
|
||||
}
|
||||
});
|
||||
let on_shared = shared.is_some();
|
||||
match PipelineHandle::new(cfg, shared) {
|
||||
Ok(h) => {
|
||||
h.set_stall_probe(Box::new(move || describe_gpu(ds_key)));
|
||||
// Before the handle is published: the first capture after
|
||||
// it builds the ring, and must build it for this device.
|
||||
ds.shared_active.store(on_shared, Ordering::Release);
|
||||
if let Ok(mut enc) = ds.encoder.lock() {
|
||||
*enc = Some(h);
|
||||
}
|
||||
if on_shared {
|
||||
log::info!("encoding on the game's own device");
|
||||
}
|
||||
}
|
||||
Err(e) => log::error!("encode pipeline: {e}"),
|
||||
}
|
||||
@@ -316,9 +355,13 @@ fn encoder_ready(ds: &crate::state::DeviceState, ds_key: usize, width: u32, heig
|
||||
false
|
||||
}
|
||||
|
||||
/// Wait for a frame's blit and turn its slot into something the encoder reads.
|
||||
/// Turn a frame's slot into something the encoder reads.
|
||||
///
|
||||
/// This is the CPU handover the two devices need: pixelforge's `VkDevice`
|
||||
/// On a device the encoder shares this is immediate: the slot's image is read
|
||||
/// in place, and the encoder's GPU work waits on the blit's timeline point, so
|
||||
/// nothing here waits at all.
|
||||
///
|
||||
/// Otherwise this is the CPU handover two devices need: pixelforge's `VkDevice`
|
||||
/// shares no timeline with the game's, so no semaphore can bridge them and
|
||||
/// somebody has to block. It used to be a thread of its own between the present
|
||||
/// hook and the encoder; it is now the first thing the encoder thread does with
|
||||
@@ -331,13 +374,19 @@ pub fn resolve_source(
|
||||
) -> Option<FrameSource> {
|
||||
let slot_index = frame.slot.as_ref()?.index();
|
||||
|
||||
if let Some(blit) = frame.blit {
|
||||
let ring = ds.capture_ring.lock().ok()?;
|
||||
let image = ring.as_ref()?.slots.get(slot_index)?.image;
|
||||
return Some(FrameSource::Shared { image, blit });
|
||||
}
|
||||
|
||||
// Copy the handles out and drop the ring lock before waiting: the present
|
||||
// hook needs that lock every frame and must not queue behind a GPU wait.
|
||||
let (fence, dmabuf_fd, stride, modifier, image, memory) = {
|
||||
let (fence, image, memory) = {
|
||||
let ring = ds.capture_ring.lock().ok()?;
|
||||
ring.as_ref()
|
||||
.and_then(|r| r.slots.get(slot_index))
|
||||
.map(|s| (s.fence, s.dmabuf_fd, s.stride, s.modifier, s.image, s.memory))?
|
||||
.map(|s| (s.fence, s.image, s.memory))?
|
||||
};
|
||||
|
||||
let waited = unsafe { (ds.fp.wait_for_fences)(ds.raw, 1, &fence, vk::TRUE, 1_000_000_000) };
|
||||
@@ -347,30 +396,59 @@ pub fn resolve_source(
|
||||
}
|
||||
|
||||
// After the fence, so the queries have landed and `WAIT` returns at once.
|
||||
if let Some(ns) = unsafe { capture::blit_gpu_time_ns(ds, slot_index) }
|
||||
if let Some(ns) = unsafe { capture::blit_gpu_time_ns(ds, slot_index, true) }
|
||||
&& let Ok(enc) = ds.encoder.lock()
|
||||
&& let Some(ref h) = *enc
|
||||
{
|
||||
h.timing.blit.record(std::time::Duration::from_nanos(ns));
|
||||
}
|
||||
|
||||
if dmabuf_fd >= 0 {
|
||||
let duped = unsafe { libc::dup(dmabuf_fd) };
|
||||
if duped < 0 {
|
||||
log::warn!("dup of capture DMA-BUF failed — frame dropped");
|
||||
return None;
|
||||
}
|
||||
return Some(FrameSource::DmaBuf {
|
||||
fd: duped,
|
||||
stride,
|
||||
// The slot's own modifier. This was hard-coded to zero, which was
|
||||
// true only because the producer could not ask for anything else.
|
||||
modifier,
|
||||
});
|
||||
}
|
||||
|
||||
match unsafe { capture::read_frame_pixels(ds, image, memory, frame.width, frame.height) } {
|
||||
Some(p) if !p.is_empty() => Some(FrameSource::Pixels(p)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the GPU has got through the capture work this layer gave it, for
|
||||
/// the stall watchdog.
|
||||
///
|
||||
/// A blit is queued behind the game's frame on the game's own queue, so a blit
|
||||
/// that never completes means that queue stopped: the game is waiting on the
|
||||
/// GPU. One that did complete means the GPU finished everything in front of
|
||||
/// it, and whatever the game is waiting for is not GPU work on that queue.
|
||||
///
|
||||
/// Never blocks. It runs exactly when something may be holding a lock forever.
|
||||
fn describe_gpu(ds_key: usize) -> String {
|
||||
let Some(ds) = DEVICE_STATE.get(&ds_key).map(|s| s.clone()) else {
|
||||
return "device gone".into();
|
||||
};
|
||||
let slots = format!(
|
||||
"{} of {} capture slots free",
|
||||
ds.capture_slots.available(),
|
||||
crate::state::CAPTURE_SLOTS
|
||||
);
|
||||
let Ok(ring) = ds.capture_ring.try_lock() else {
|
||||
return format!("{slots}; capture ring locked");
|
||||
};
|
||||
let Some(ring) = ring.as_ref() else {
|
||||
return format!("{slots}; no capture ring");
|
||||
};
|
||||
if let Some(shared) = ds.shared.as_ref()
|
||||
&& !ring.blit_timeline.is_null()
|
||||
{
|
||||
let reached = shared
|
||||
.counter(ring.blit_timeline)
|
||||
.map_or("unknown".to_string(), |v| v.to_string());
|
||||
return format!(
|
||||
"{slots}; last blit submitted signals {}, the GPU has reached {reached}",
|
||||
ring.blit_value
|
||||
);
|
||||
}
|
||||
let pending = ring
|
||||
.slots
|
||||
.iter()
|
||||
.filter(|s| unsafe { (ds.fp.wait_for_fences)(ds.raw, 1, &s.fence, vk::TRUE, 0) }
|
||||
== vk::Result::TIMEOUT)
|
||||
.count();
|
||||
format!("{slots}; {pending} blits submitted and not yet complete")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
//! How long the encoder takes to actually reach a bitrate it was told to use.
|
||||
//!
|
||||
//! # Why this exists
|
||||
//!
|
||||
//! The bitrate controller decides how often it is worth deciding. Everything
|
||||
//! else about its design follows from one number nobody has measured: the time
|
||||
//! between `set_target_bitrate` and the encoder actually producing that rate.
|
||||
//!
|
||||
//! Simulated against a measured 1000-mile path, the difference is the whole
|
||||
//! design. With the encoder settling in a quarter second, a controller sampling
|
||||
//! the send queue five times a second holds the queue at 50 ms. With the
|
||||
//! encoder taking a second, the same controller at any rate holds it at about
|
||||
//! 1400 ms, which is unplayable, and sampling faster buys nothing at all --
|
||||
//! there is no point reacting quicker than the thing being steered can move.
|
||||
//!
|
||||
//! So this measures it, rather than picking a control rate and hoping.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! Self-driving on purpose. The ladder and the dwell are fixed here so that two
|
||||
//! people on two networks produce numbers that can be laid beside each other;
|
||||
//! if the steps came from a person moving a slider, they would not be.
|
||||
//!
|
||||
//! While a sweep is running the encoder ignores bitrates from anywhere else.
|
||||
//! A controller adjusting in the background would be a second hand on the same
|
||||
//! dial, and the measurement would describe the argument rather than the
|
||||
//! encoder.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Targets to step through, in kbps.
|
||||
///
|
||||
/// Large steps and small ones, downward and upward. Downward matters most --
|
||||
/// that is the direction taken under congestion, and the direction where being
|
||||
/// slow costs latency rather than picture -- but an encoder can easily be quick
|
||||
/// one way and slow the other, so both are here.
|
||||
const LADDER: [u32; 6] = [6_000, 1_500, 6_000, 3_000, 1_000, 4_000];
|
||||
|
||||
/// How long to sit at each rung.
|
||||
///
|
||||
/// Long enough to settle and then be seen to be steady. If settling turns out
|
||||
/// to take longer than this, that is itself the answer and it is reported as a
|
||||
/// failure to settle rather than as a number.
|
||||
const DWELL: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Window over which the produced rate is measured.
|
||||
///
|
||||
/// Short enough to see a change quickly, long enough that one large frame does
|
||||
/// not look like a rate. At sixty frames a second this averages about thirty.
|
||||
const WINDOW: Duration = Duration::from_millis(500);
|
||||
|
||||
/// How close counts as arrived.
|
||||
const TOLERANCE: f32 = 0.10;
|
||||
|
||||
/// How long it must stay inside the tolerance to count as settled, rather than
|
||||
/// having passed through on the way somewhere else.
|
||||
const HOLD: Duration = Duration::from_millis(500);
|
||||
|
||||
/// What one rung of the ladder turned out to cost.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct StepReport {
|
||||
pub from_kbps: u32,
|
||||
pub to_kbps: u32,
|
||||
/// Milliseconds from the command to the produced rate first being inside
|
||||
/// the tolerance and staying there. `None` if it never settled.
|
||||
pub settle_ms: Option<u32>,
|
||||
pub settle_frames: Option<u32>,
|
||||
/// Produced rate over the last second of the dwell, as a fraction of the
|
||||
/// target. This is the overshoot the controller has to divide out: a
|
||||
/// hardware encoder asked for 1000 does not produce 1000.
|
||||
pub steady_ratio: f32,
|
||||
/// Keyframes during the rung.
|
||||
pub keyframes: u32,
|
||||
/// The largest keyframe seen, in bytes.
|
||||
///
|
||||
/// Reported rather than folded in, because it is a different quantity with
|
||||
/// a different consumer. Settling is about the rate control finding its
|
||||
/// operating point; a keyframe is a single burst handed to the transport
|
||||
/// whole. The controller needs both and must not confuse them: measured at
|
||||
/// 4 s GOP, a run where keyframes were counted in the rate said settling
|
||||
/// took 2266 ms where the same encoder with no keyframes said 450 ms, and
|
||||
/// in one case the number went *down* when keyframes were added. That is
|
||||
/// not an encoder being erratic, it is a window catching an IDR.
|
||||
pub keyframe_bytes: u32,
|
||||
/// How long that keyframe alone occupies the link at this rung's target,
|
||||
/// in milliseconds. This is the burst a queue has to absorb, and the
|
||||
/// reason a queue setpoint cannot simply be set below it.
|
||||
pub keyframe_ms: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Phase {
|
||||
/// Letting the encoder reach a steady state before the first step.
|
||||
WarmUp,
|
||||
Stepping,
|
||||
Done,
|
||||
}
|
||||
|
||||
pub struct RateProbe {
|
||||
phase: Phase,
|
||||
rung: usize,
|
||||
/// When the current rung was commanded.
|
||||
began: Instant,
|
||||
current_kbps: u32,
|
||||
/// `(when, bytes)` inside the measurement window, delta frames only.
|
||||
///
|
||||
/// Keyframes are deliberately absent. One IDR is worth many delta frames,
|
||||
/// so a half-second window containing one reports a rate several times the
|
||||
/// truth, leaves the tolerance band, and restarts the settle clock -- which
|
||||
/// measures the GOP rather than the encoder.
|
||||
samples: Vec<(Instant, u32)>,
|
||||
keyframe_bytes: u32,
|
||||
/// Frames since the current rung was commanded.
|
||||
frames: u32,
|
||||
keyframes: u32,
|
||||
/// When the produced rate first entered the tolerance, if it still is.
|
||||
inside_since: Option<Instant>,
|
||||
settled: Option<(u32, u32)>,
|
||||
/// The last second of the dwell, for the steady-state ratio.
|
||||
steady: Vec<(Instant, u32)>,
|
||||
reports: Vec<StepReport>,
|
||||
}
|
||||
|
||||
impl RateProbe {
|
||||
/// A probe that has not started stepping yet.
|
||||
pub fn new(now: Instant, starting_kbps: u32) -> Self {
|
||||
Self {
|
||||
phase: Phase::WarmUp,
|
||||
rung: 0,
|
||||
began: now,
|
||||
current_kbps: starting_kbps,
|
||||
samples: Vec::new(),
|
||||
keyframe_bytes: 0,
|
||||
frames: 0,
|
||||
keyframes: 0,
|
||||
inside_since: None,
|
||||
settled: None,
|
||||
steady: Vec::new(),
|
||||
reports: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finished(&self) -> bool {
|
||||
self.phase == Phase::Done
|
||||
}
|
||||
|
||||
/// Whether a bitrate from elsewhere should be ignored.
|
||||
pub fn owns_the_bitrate(&self) -> bool {
|
||||
self.phase != Phase::Done
|
||||
}
|
||||
|
||||
/// One encoded frame.
|
||||
pub fn observe(&mut self, now: Instant, bytes: u32, keyframe: bool) {
|
||||
if keyframe {
|
||||
self.keyframe_bytes = self.keyframe_bytes.max(bytes);
|
||||
} else {
|
||||
self.samples.push((now, bytes));
|
||||
}
|
||||
self.samples
|
||||
.retain(|(t, _)| now.duration_since(*t) <= WINDOW);
|
||||
// The steady-state ratio *does* include keyframes: it answers what the
|
||||
// link actually carries for a given target, which is the whole output.
|
||||
self.steady.push((now, bytes));
|
||||
self.steady
|
||||
.retain(|(t, _)| now.duration_since(*t) <= Duration::from_secs(1));
|
||||
if self.phase != Phase::Stepping {
|
||||
return;
|
||||
}
|
||||
self.frames += 1;
|
||||
if keyframe {
|
||||
self.keyframes += 1;
|
||||
}
|
||||
if self.settled.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(rate) = self.measured_kbps(now) else {
|
||||
return;
|
||||
};
|
||||
let drift =
|
||||
(rate as f32 - self.current_kbps as f32).abs() / self.current_kbps.max(1) as f32;
|
||||
if drift > TOLERANCE {
|
||||
// Left the band, so whatever it was doing was not settling.
|
||||
self.inside_since = None;
|
||||
return;
|
||||
}
|
||||
let entered = *self.inside_since.get_or_insert(now);
|
||||
if now.duration_since(entered) >= HOLD {
|
||||
// Credit the moment it arrived, not the moment it had stayed long
|
||||
// enough to prove it: the hold is evidence about the arrival, not
|
||||
// part of the time the encoder took.
|
||||
let ms = entered.duration_since(self.began).as_millis() as u32;
|
||||
self.settled = Some((ms, self.frames));
|
||||
}
|
||||
}
|
||||
|
||||
/// Produced rate over the window, or `None` before there is a window's worth.
|
||||
fn measured_kbps(&self, now: Instant) -> Option<u32> {
|
||||
let oldest = self.samples.first()?.0;
|
||||
let span = now.duration_since(oldest);
|
||||
if span < WINDOW / 2 {
|
||||
return None;
|
||||
}
|
||||
let bits: u64 = self.samples.iter().map(|(_, b)| u64::from(*b) * 8).sum();
|
||||
Some((bits as f64 / span.as_secs_f64() / 1000.0) as u32)
|
||||
}
|
||||
|
||||
/// The next target to apply, when the current rung is done.
|
||||
pub fn due_step(&mut self, now: Instant) -> Option<u32> {
|
||||
if now.duration_since(self.began) < DWELL {
|
||||
return None;
|
||||
}
|
||||
if self.phase == Phase::Stepping {
|
||||
self.close_rung(now);
|
||||
}
|
||||
if self.rung >= LADDER.len() {
|
||||
self.phase = Phase::Done;
|
||||
return None;
|
||||
}
|
||||
let next = LADDER[self.rung];
|
||||
self.rung += 1;
|
||||
self.phase = Phase::Stepping;
|
||||
self.began = now;
|
||||
self.frames = 0;
|
||||
self.keyframes = 0;
|
||||
self.keyframe_bytes = 0;
|
||||
self.inside_since = None;
|
||||
self.settled = None;
|
||||
let from = self.current_kbps;
|
||||
self.current_kbps = next;
|
||||
let _ = from;
|
||||
Some(next)
|
||||
}
|
||||
|
||||
fn close_rung(&mut self, now: Instant) {
|
||||
let steady_bits: u64 = self.steady.iter().map(|(_, b)| u64::from(*b) * 8).sum();
|
||||
let span = self
|
||||
.steady
|
||||
.first()
|
||||
.map(|(t, _)| now.duration_since(*t).as_secs_f64())
|
||||
.unwrap_or(0.0);
|
||||
let steady_kbps = if span > 0.0 {
|
||||
steady_bits as f64 / span / 1000.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let from = self.reports.last().map_or(0, |r| r.to_kbps);
|
||||
self.reports.push(StepReport {
|
||||
from_kbps: from,
|
||||
to_kbps: self.current_kbps,
|
||||
settle_ms: self.settled.map(|(ms, _)| ms),
|
||||
settle_frames: self.settled.map(|(_, f)| f),
|
||||
steady_ratio: (steady_kbps / f64::from(self.current_kbps.max(1))) as f32,
|
||||
keyframes: self.keyframes,
|
||||
keyframe_bytes: self.keyframe_bytes,
|
||||
keyframe_ms: (u64::from(self.keyframe_bytes) * 8 / u64::from(self.current_kbps.max(1)))
|
||||
as u32,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn reports(&self) -> &[StepReport] {
|
||||
&self.reports
|
||||
}
|
||||
|
||||
/// The number the controller design turns on: the slowest settle observed.
|
||||
///
|
||||
/// The slowest rather than the average, for the same reason the bitrate
|
||||
/// controller reads the worst client's report: a loop that keeps up with
|
||||
/// the typical step and not the worst one is a loop that falls behind
|
||||
/// exactly when the path is changing, which is the only time it matters.
|
||||
pub fn worst_settle_ms(&self) -> Option<u32> {
|
||||
self.reports.iter().map(|r| r.settle_ms).max().flatten()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Feed a probe frames at `kbps` for `secs`, sixty a second.
|
||||
fn feed(probe: &mut RateProbe, start: Instant, secs: f32, kbps: u32) -> Instant {
|
||||
let frames = (secs * 60.0) as u32;
|
||||
let bytes = (u64::from(kbps) * 1000 / 8 / 60) as u32;
|
||||
let mut now = start;
|
||||
for i in 1..=frames {
|
||||
// From the start each time: stepping by a rounded frame interval
|
||||
// drifts, and a helper that loses 4% of every second makes a probe
|
||||
// measuring seconds look broken when it is not.
|
||||
now = start + Duration::from_secs_f64(f64::from(i) / 60.0);
|
||||
probe.observe(now, bytes, false);
|
||||
}
|
||||
now
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_encoder_that_changes_at_once_settles_at_once() {
|
||||
let t0 = Instant::now();
|
||||
let mut p = RateProbe::new(t0, 6_000);
|
||||
let mut now = feed(&mut p, t0, 5.1, 6_000);
|
||||
let step = p.due_step(now).expect("first rung");
|
||||
assert_eq!(step, 6_000);
|
||||
now = feed(&mut p, now, 5.1, 6_000);
|
||||
assert_eq!(p.due_step(now), Some(1_500));
|
||||
// Produces the new rate immediately.
|
||||
feed(&mut p, now, 2.0, 1_500);
|
||||
let r = p.reports();
|
||||
assert!(!r.is_empty());
|
||||
assert!(
|
||||
r[0].settle_ms.is_some_and(|ms| ms < 1_200),
|
||||
"an instant encoder reported {:?}",
|
||||
r[0].settle_ms
|
||||
);
|
||||
}
|
||||
|
||||
/// The case that would kill the fast-loop design, and must be visible.
|
||||
#[test]
|
||||
fn an_encoder_that_never_gets_there_reports_no_settle() {
|
||||
let t0 = Instant::now();
|
||||
let mut p = RateProbe::new(t0, 6_000);
|
||||
let mut now = feed(&mut p, t0, 5.1, 6_000);
|
||||
p.due_step(now);
|
||||
now = feed(&mut p, now, 5.1, 6_000);
|
||||
assert_eq!(p.due_step(now), Some(1_500));
|
||||
// Ignores the command completely.
|
||||
now = feed(&mut p, now, 5.1, 6_000);
|
||||
p.due_step(now);
|
||||
let r = p.reports();
|
||||
let step = r
|
||||
.iter()
|
||||
.find(|r| r.to_kbps == 1_500)
|
||||
.expect("the 1500 rung");
|
||||
assert_eq!(
|
||||
step.settle_ms, None,
|
||||
"an encoder that ignored us looked settled"
|
||||
);
|
||||
assert!(
|
||||
step.steady_ratio > 3.0,
|
||||
"steady ratio {} did not show it producing four times the target",
|
||||
step.steady_ratio
|
||||
);
|
||||
}
|
||||
|
||||
/// Overshoot is the other number the controller needs, so it must be real.
|
||||
#[test]
|
||||
fn steady_ratio_reports_the_overshoot() {
|
||||
let t0 = Instant::now();
|
||||
let mut p = RateProbe::new(t0, 6_000);
|
||||
let mut now = feed(&mut p, t0, 5.1, 6_000);
|
||||
p.due_step(now);
|
||||
now = feed(&mut p, now, 5.1, 6_000);
|
||||
p.due_step(now);
|
||||
// Asked for 1500, produces 1875: the 25% overshoot measured on hardware.
|
||||
now = feed(&mut p, now, 5.1, 1_875);
|
||||
p.due_step(now);
|
||||
let step = p.reports().iter().find(|r| r.to_kbps == 1_500).unwrap();
|
||||
assert!(
|
||||
(step.steady_ratio - 1.25).abs() < 0.1,
|
||||
"steady ratio {} should be about 1.25",
|
||||
step.steady_ratio
|
||||
);
|
||||
}
|
||||
|
||||
/// The confound that made a 4 s GOP look like a three-times slower
|
||||
/// encoder, including making one rung appear *faster* when keyframes were
|
||||
/// added -- which no encoder does, and which gave the measurement away.
|
||||
#[test]
|
||||
fn a_keyframe_landing_mid_window_does_not_delay_the_reported_settle() {
|
||||
let t0 = Instant::now();
|
||||
let mut p = RateProbe::new(t0, 6_000);
|
||||
let mut now = feed(&mut p, t0, 5.1, 6_000);
|
||||
p.due_step(now);
|
||||
now = feed(&mut p, now, 5.1, 6_000);
|
||||
assert_eq!(p.due_step(now), Some(1_500));
|
||||
|
||||
// Settles immediately, but an IDR worth a second of bitrate lands in
|
||||
// the middle of the window that is meant to prove it.
|
||||
let bytes = (1_500u64 * 1000 / 8 / 60) as u32;
|
||||
let base = now;
|
||||
for i in 1..=320u32 {
|
||||
now = base + Duration::from_secs_f64(f64::from(i) / 60.0);
|
||||
let key = i == 40;
|
||||
p.observe(now, if key { 1_500 * 1000 / 8 } else { bytes }, key);
|
||||
}
|
||||
p.due_step(now);
|
||||
let step = p.reports().iter().find(|r| r.to_kbps == 1_500).unwrap();
|
||||
assert!(
|
||||
step.settle_ms.is_some_and(|ms| ms < 1_000),
|
||||
"an encoder that settled at once reported {:?} because of one keyframe",
|
||||
step.settle_ms
|
||||
);
|
||||
// And the burst is still reported, because the queue has to absorb it.
|
||||
assert_eq!(step.keyframes, 1);
|
||||
assert!(
|
||||
step.keyframe_ms >= 900,
|
||||
"a keyframe worth a second of bitrate reported {} ms",
|
||||
step.keyframe_ms
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sweep_ends_and_does_not_step_forever() {
|
||||
let t0 = Instant::now();
|
||||
let mut p = RateProbe::new(t0, 6_000);
|
||||
let mut now = t0;
|
||||
for _ in 0..(LADDER.len() + 2) {
|
||||
now = feed(&mut p, now, 5.1, 3_000);
|
||||
p.due_step(now);
|
||||
}
|
||||
assert!(p.finished());
|
||||
assert!(
|
||||
!p.owns_the_bitrate(),
|
||||
"a finished sweep still holds the dial"
|
||||
);
|
||||
assert_eq!(p.reports().len(), LADDER.len());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
// slots.rs — ownership of the capture ring's destination buffers
|
||||
//
|
||||
// A captured frame travels from the present hook, through the capture worker,
|
||||
// into the encoder thread, and its DMA-BUF must not be written again until the
|
||||
// into the encoder thread, and its image must not be written again until the
|
||||
// encoder has finished reading it. Tracking that by hand across three threads
|
||||
// is how the single-buffer version got it wrong. Instead the slot index is
|
||||
// carried by a guard that returns it to the pool when it drops, wherever that
|
||||
|
||||
@@ -25,21 +25,10 @@ pub const CAPTURE_SLOTS: usize = 4;
|
||||
pub struct CaptureSlot {
|
||||
pub image: vk::Image,
|
||||
pub memory: vk::DeviceMemory,
|
||||
/// Exported once at allocation and duplicated per frame. -1 if the export
|
||||
/// failed, which sends that frame down the CPU readback path instead.
|
||||
pub dmabuf_fd: std::os::raw::c_int,
|
||||
pub stride: u32,
|
||||
/// The DRM format modifier the driver gave this slot's image.
|
||||
///
|
||||
/// Carried per slot rather than assumed, and passed to the importer, which
|
||||
/// creates its side with this exact value. It used to be hard-coded to
|
||||
/// `DRM_FORMAT_MOD_LINEAR` on both sides — true at the time, because the
|
||||
/// producer only ever asked for linear.
|
||||
pub modifier: u64,
|
||||
/// Signalled when this slot's blit has finished reading the swapchain and
|
||||
/// writing the slot. The capture worker waits on it before handing the
|
||||
/// DMA-BUF to the encoder, which reads it from a different VkDevice and so
|
||||
/// cannot be synchronised with a semaphore.
|
||||
/// writing the slot. Where the encoder has a device of its own, the encoder
|
||||
/// thread waits on it before reading the slot back on the CPU; a device of
|
||||
/// its own shares no timeline with the game's.
|
||||
pub fence: vk::Fence,
|
||||
}
|
||||
|
||||
@@ -75,12 +64,14 @@ pub struct CaptureRing {
|
||||
pub timestamp_period: f32,
|
||||
/// The extent `blits` were recorded for.
|
||||
///
|
||||
/// The ring is kept when the swapchain shrinks — `ensure_capture_ring`
|
||||
/// accepts a ring at least as large as the request — so the extent can
|
||||
/// change under a ring that is not rebuilt, and the recordings have to
|
||||
/// follow it even though the images do not.
|
||||
/// Kept separate from `size` because a swapchain can be recreated at the
|
||||
/// same extent — on a format or present-mode change — which invalidates the
|
||||
/// recordings without invalidating the images.
|
||||
pub blit_extent: vk::Extent2D,
|
||||
pub size: (u32, u32, vk::Format),
|
||||
/// Which ring this is, counting from the device's first. For the log, so a
|
||||
/// resize shows up as the ring it caused.
|
||||
pub generation: u64,
|
||||
/// Queue family the command pool was created for. Command buffers may only
|
||||
/// be submitted to a queue of the family their pool belongs to, so a
|
||||
/// present arriving on a different family rebuilds the ring rather than
|
||||
@@ -102,6 +93,15 @@ pub struct CaptureRing {
|
||||
/// cannot reacquire it until the present that waited on this semaphore is
|
||||
/// done.
|
||||
pub present_wait: Vec<vk::Semaphore>,
|
||||
/// Timeline semaphore every blit signals, one value higher each time, or
|
||||
/// null when the encoder does not share this device.
|
||||
///
|
||||
/// On a shared device this is the whole handover: the frame carries the
|
||||
/// value its blit signals, and the encoder's GPU work waits on it. Nothing
|
||||
/// waits on the CPU.
|
||||
pub blit_timeline: vk::Semaphore,
|
||||
/// The value the last blit signalled.
|
||||
pub blit_value: u64,
|
||||
}
|
||||
|
||||
/// Index into [`CaptureRing::blits`] for one (swapchain image, slot) pair.
|
||||
@@ -177,6 +177,17 @@ pub struct DeviceState {
|
||||
pub raw: vk::Device,
|
||||
pub physical_device: vk::PhysicalDevice,
|
||||
pub fp: NextDeviceFn,
|
||||
/// The encoder's view of this device, when it runs on it. `None` when the
|
||||
/// device could not be created with what the encoder needs, in which case
|
||||
/// the encoder uses a device of its own.
|
||||
pub shared: Option<crate::shared::SharedDevice>,
|
||||
/// Whether the encode pipeline really runs on `shared`. Set once the
|
||||
/// pipeline has been built on it; until then, and for good if that fails,
|
||||
/// capture works as it does for a device of the encoder's own.
|
||||
pub shared_active: std::sync::atomic::AtomicBool,
|
||||
/// The loader's callback for stamping dispatchable objects this layer
|
||||
/// creates itself. See [`crate::device::stamp`].
|
||||
pub set_loader_data: Option<crate::PFN_vkSetDeviceLoaderData>,
|
||||
|
||||
// Phase 1: shader / pipeline
|
||||
pub shader_registry: DashMap<u64, u64>,
|
||||
@@ -197,7 +208,7 @@ pub struct DeviceState {
|
||||
pub hudless_memory: std::sync::Mutex<Option<vk::DeviceMemory>>,
|
||||
pub hudless_size: std::sync::Mutex<(u32, u32, vk::Format)>,
|
||||
|
||||
// Phase 4: final-frame capture (DMA-BUF exportable)
|
||||
// Phase 4: final-frame capture
|
||||
pub capture_ring: std::sync::Mutex<Option<CaptureRing>>,
|
||||
/// Which ring slots are free. Held separately from the ring itself so a
|
||||
/// slot can be returned from the encoder thread without taking the lock
|
||||
@@ -219,6 +230,9 @@ pub struct DeviceState {
|
||||
pub swapchain_transfer_src: std::sync::atomic::AtomicBool,
|
||||
|
||||
pub frame_counter: std::sync::atomic::AtomicU64,
|
||||
/// Rings built for this device so far. Names the current one; see
|
||||
/// [`CaptureRing::generation`].
|
||||
pub ring_generation: std::sync::atomic::AtomicU64,
|
||||
|
||||
// Phase 3/4: per-frame HUD detection flags
|
||||
pub hud_detected_frame: std::sync::atomic::AtomicBool,
|
||||
@@ -229,7 +243,6 @@ pub struct DeviceState {
|
||||
// Phase 7: encode + IPC pipeline (lazy-init on first frame)
|
||||
pub encoder: std::sync::Mutex<Option<PipelineHandle>>,
|
||||
|
||||
|
||||
// ── Frame-rate throttle ───────────────────────────────────────────
|
||||
/// Decides which presented frames are worth capturing. Consulted in the
|
||||
/// present hook, before any GPU work is queued, so a dropped frame costs
|
||||
@@ -252,6 +265,16 @@ pub struct DeviceState {
|
||||
pub encoder_starting: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl DeviceState {
|
||||
/// The shared device, when the encoder is actually running on it.
|
||||
pub fn shared_encoder(&self) -> Option<&crate::shared::SharedDevice> {
|
||||
self.shared.as_ref().filter(|_| {
|
||||
self.shared_active
|
||||
.load(std::sync::atomic::Ordering::Acquire)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-command-buffer state ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
|
||||
@@ -125,6 +125,13 @@ pub unsafe extern "system" fn vkCreateSwapchainKHR(
|
||||
vk::Result::SUCCESS
|
||||
}
|
||||
|
||||
/// Whether `swapchain` is the one this layer tracks, which is the one most
|
||||
/// recently created. A swapchain retired through `oldSwapchain` stays valid
|
||||
/// until destroyed, and calls on it must not touch the tracked state.
|
||||
pub fn is_current(tracked: Option<vk::SwapchainKHR>, swapchain: vk::SwapchainKHR) -> bool {
|
||||
swapchain != vk::SwapchainKHR::null() && tracked == Some(swapchain)
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub unsafe extern "system" fn vkDestroySwapchainKHR(
|
||||
device: vk::Device,
|
||||
@@ -133,8 +140,17 @@ pub unsafe extern "system" fn vkDestroySwapchainKHR(
|
||||
) {
|
||||
let key = unsafe { dispatch_key(device.as_raw() as *const c_void) };
|
||||
if let Some(ds) = DEVICE_STATE.get(&key) {
|
||||
*ds.swapchain.lock().unwrap() = None;
|
||||
*ds.swapchain_images.lock().unwrap() = Vec::new();
|
||||
// Only the swapchain being tracked. A game recreating through
|
||||
// `oldSwapchain` destroys the retired one after its replacement is
|
||||
// created and its images fetched, and clearing then would leave the
|
||||
// live swapchain with no images: every present after would skip
|
||||
// capture, silently, until the next recreation.
|
||||
let mut current = ds.swapchain.lock().unwrap();
|
||||
if is_current(*current, swapchain) {
|
||||
*current = None;
|
||||
*ds.swapchain_images.lock().unwrap() = Vec::new();
|
||||
}
|
||||
drop(current);
|
||||
if let Some(destroy_fn) = ds.fp.destroy_swapchain_khr {
|
||||
unsafe { destroy_fn(device, swapchain, p_allocator) };
|
||||
}
|
||||
@@ -171,7 +187,8 @@ pub unsafe extern "system" fn vkGetSwapchainImagesKHR(
|
||||
return result;
|
||||
}
|
||||
|
||||
if !p_swapchain_images.is_null() {
|
||||
// Images of a retired swapchain are not the ones capture reads.
|
||||
if !p_swapchain_images.is_null() && is_current(*ds.swapchain.lock().unwrap(), swapchain) {
|
||||
let count = unsafe { *p_swapchain_image_count as usize };
|
||||
let images = unsafe { std::slice::from_raw_parts(p_swapchain_images, count) };
|
||||
*ds.swapchain_images.lock().unwrap() = images.to_vec();
|
||||
@@ -231,9 +248,11 @@ pub unsafe extern "system" fn vkAcquireNextImageKHR(
|
||||
return vk::Result::ERROR_EXTENSION_NOT_PRESENT;
|
||||
};
|
||||
|
||||
crate::present::note_present(&ds, crate::encode::PresentStep::Acquiring);
|
||||
let started = std::time::Instant::now();
|
||||
let result = unsafe { acquire(device, swapchain, timeout, semaphore, fence, p_image_index) };
|
||||
record_acquire(&ds, started.elapsed());
|
||||
crate::present::note_present(&ds, crate::encode::PresentStep::InGame);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -250,8 +269,37 @@ pub unsafe extern "system" fn vkAcquireNextImage2KHR(
|
||||
return vk::Result::ERROR_EXTENSION_NOT_PRESENT;
|
||||
};
|
||||
|
||||
crate::present::note_present(&ds, crate::encode::PresentStep::Acquiring);
|
||||
let started = std::time::Instant::now();
|
||||
let result = unsafe { acquire(device, p_acquire_info, p_image_index) };
|
||||
record_acquire(&ds, started.elapsed());
|
||||
crate::present::note_present(&ds, crate::encode::PresentStep::InGame);
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sc(raw: u64) -> vk::SwapchainKHR {
|
||||
vk::SwapchainKHR::from_raw(raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tracked_swapchain_is_current() {
|
||||
assert!(is_current(Some(sc(2)), sc(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retired_swapchain_is_not() {
|
||||
// Created 2 with oldSwapchain = 1; destroying 1 afterwards must leave
|
||||
// 2's state alone.
|
||||
assert!(!is_current(Some(sc(2)), sc(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_current_once_the_tracked_one_is_gone() {
|
||||
assert!(!is_current(None, sc(1)));
|
||||
assert!(!is_current(None, vk::SwapchainKHR::null()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,8 +28,6 @@ smithay = { version = "0.7", default-features = false, features = [
|
||||
# Wayland client – connects to the host compositor to forward buffers.
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging", "server"] }
|
||||
# Needed to generate the gamescope_swapchain protocol bindings.
|
||||
wayland-scanner = "0.31"
|
||||
wayland-backend = "0.3"
|
||||
|
||||
# Event loop
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/protocols/gamescope-swapchain.xml");
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use smithay::desktop::Window;
|
||||
use smithay::input::pointer::{CursorImageStatus, PointerHandle};
|
||||
use smithay::input::{Seat, SeatHandler, SeatState};
|
||||
use smithay::output::Output;
|
||||
use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel;
|
||||
use smithay::reexports::wayland_server::protocol::wl_buffer;
|
||||
use smithay::reexports::wayland_server::protocol::wl_output::WlOutput;
|
||||
use smithay::reexports::wayland_server::protocol::wl_seat::WlSeat;
|
||||
@@ -119,7 +120,6 @@ impl CompositorHandler for NescopeState {
|
||||
|
||||
fn destroyed(&mut self, surface: &WlSurface) {
|
||||
self.hdr.surface_destroyed(surface);
|
||||
self.vulkan_surfaces.remove(surface);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ impl DmabufHandler for NescopeState {
|
||||
_dmabuf: Dmabuf,
|
||||
notifier: ImportNotifier,
|
||||
) {
|
||||
// Accept unconditionally — libhudless reads buffers
|
||||
// Accept unconditionally — the nescapture layer reads buffers
|
||||
// directly from the game's Vulkan queue; nescope doesn't need to.
|
||||
let _ = notifier.successful::<NescopeState>();
|
||||
}
|
||||
@@ -187,6 +187,29 @@ impl XdgShellHandler for NescopeState {
|
||||
self.determine_and_apply_focus();
|
||||
}
|
||||
|
||||
// Granted, not merely acknowledged. The default answers with a configure
|
||||
// that lacks the fullscreen state, which a client reads as a refusal: Wine
|
||||
// then asks again on every window update and never treats its window as
|
||||
// fullscreen, so a game switching to exclusive fullscreen -- Control does
|
||||
// this on leaving its title screen -- stalls in the transition and stays
|
||||
// where it was. Wine also scales an emulated display mode up to the output
|
||||
// only for a fullscreen window.
|
||||
fn fullscreen_request(&mut self, surface: ToplevelSurface, _output: Option<WlOutput>) {
|
||||
surface.with_pending_state(|state| {
|
||||
state.states.set(xdg_toplevel::State::Fullscreen);
|
||||
state.size = Some((self.width as i32, self.height as i32).into());
|
||||
});
|
||||
surface.send_configure();
|
||||
}
|
||||
|
||||
// Still the size of the output: there is nowhere else for a window to be.
|
||||
fn unfullscreen_request(&mut self, surface: ToplevelSurface) {
|
||||
surface.with_pending_state(|state| {
|
||||
state.states.unset(xdg_toplevel::State::Fullscreen);
|
||||
});
|
||||
surface.send_configure();
|
||||
}
|
||||
|
||||
fn new_popup(&mut self, _: PopupSurface, _: PositionerState) {}
|
||||
fn grab(&mut self, _: PopupSurface, _: WlSeat, _: Serial) {}
|
||||
fn reposition_request(&mut self, _: PopupSurface, _: PositionerState, _: u32) {}
|
||||
|
||||
+564
-286
File diff suppressed because it is too large
Load Diff
@@ -106,11 +106,6 @@ pub fn process_input(event: InputEvent, state: &mut NescopeState) {
|
||||
_ => state.last_pointer_activity = std::time::Instant::now(),
|
||||
}
|
||||
|
||||
// One-time X11 focus reset when the gamescope WSI surface is active.
|
||||
if state.override_surface.is_some() && state.x11_focus_needs_reset {
|
||||
state.sync_x11_focus();
|
||||
}
|
||||
|
||||
match event {
|
||||
InputEvent::KeyDown { keycode } => {
|
||||
if let Some(kb) = state.seat.get_keyboard() {
|
||||
@@ -363,21 +358,6 @@ fn clamp_cursor(state: &mut NescopeState) {
|
||||
|
||||
/// Find the focused target under the current cursor position.
|
||||
pub fn surface_under(state: &NescopeState) -> Option<(KeyboardFocusTarget, Point<f64, Logical>)> {
|
||||
if state.override_surface.is_some() {
|
||||
if let Some(wid) = state.focused_x11_window {
|
||||
for window in state.space.elements() {
|
||||
if let Some(x11) = window.x11_surface() {
|
||||
if x11.window_id() == wid {
|
||||
let loc = state.space.element_geometry(window)?.loc;
|
||||
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (window, loc) = state.space.element_under(state.cursor_position)?;
|
||||
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
|
||||
}
|
||||
|
||||
// Try element_under first
|
||||
if let Some((window, loc)) = state.space.element_under(state.cursor_position) {
|
||||
return Some((KeyboardFocusTarget::Window(window.clone()), loc.to_f64()));
|
||||
|
||||
+44
-28
@@ -4,7 +4,7 @@
|
||||
//!
|
||||
//! nescope creates a virtual Wayland output, starts XWayland, and gives games
|
||||
//! a complete compositor environment. Frames are captured externally by a
|
||||
//! Vulkan interception library (`hudless`); nescope itself
|
||||
//! Vulkan interception library (`nescapture`); nescope itself
|
||||
//! never allocates a GBM pool or forwards DMA-BUFs.
|
||||
//!
|
||||
//! # Usage
|
||||
@@ -17,7 +17,7 @@
|
||||
//! --height <N> Output height [default: 1080]
|
||||
//! --fps <N> Virtual refresh rate, advertised only [default: 60]
|
||||
//! --frame-callback-hz <N> wl_surface.frame cadence [default: 1000]
|
||||
//! --hdr Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain)
|
||||
//! --hdr Enable HDR colour management (wp_color_manager_v1)
|
||||
//! --socket <NAME> Wayland socket name [default: nescope-0]
|
||||
//! ```
|
||||
//!
|
||||
@@ -67,7 +67,6 @@ mod hdr;
|
||||
mod input;
|
||||
mod input_ipc;
|
||||
mod libinput_backend;
|
||||
mod protocols;
|
||||
//mod screenshot_ipc;
|
||||
//mod screenshot_wire;
|
||||
mod state;
|
||||
@@ -80,6 +79,22 @@ use state::{CalloopData, ClientState, NescopeState};
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A flag that can also arrive as an environment variable.
|
||||
///
|
||||
/// `--hdr` on its own still means true. The difference is what a value may be:
|
||||
/// clap's own bool parser takes `true` and `false` and nothing else, so
|
||||
/// `NESCOPE_HDR=1` -- which is how every other environment variable in this
|
||||
/// stack is written, and the first thing anyone tries -- was rejected outright.
|
||||
fn flag_value(value: &str) -> Result<bool, String> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => Ok(true),
|
||||
"0" | "false" | "no" | "off" | "" => Ok(false),
|
||||
other => Err(std::format!(
|
||||
"expected 1 or 0 (true/false, yes/no and on/off are also taken), got {other:?}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(
|
||||
name = "nescope",
|
||||
@@ -125,8 +140,15 @@ struct Args {
|
||||
#[arg(long, default_value = "1000", env = "NESCOPE_FRAME_CALLBACK_HZ")]
|
||||
frame_callback_hz: u32,
|
||||
|
||||
/// Enable HDR protocols (wp_color_management_v1 + gamescope_swapchain_factory_v2).
|
||||
#[arg(long, env = "NESCOPE_HDR")]
|
||||
/// Enable HDR colour management (`wp_color_manager_v1`).
|
||||
#[arg(
|
||||
long,
|
||||
env = "NESCOPE_HDR",
|
||||
num_args = 0..=1,
|
||||
default_value_t = false,
|
||||
default_missing_value = "true",
|
||||
value_parser = flag_value,
|
||||
)]
|
||||
hdr: bool,
|
||||
|
||||
/// Run XWayland, for Linux-native software with no Wayland support.
|
||||
@@ -137,7 +159,14 @@ struct Args {
|
||||
/// which is what the launch environment does -- and HDR is only offered on
|
||||
/// the Wayland surface, so a game routed through XWayland loses it too.
|
||||
/// Turn this on for the shrinking set of X11-only native software.
|
||||
#[arg(long, env = "NESCOPE_XWAYLAND")]
|
||||
#[arg(
|
||||
long,
|
||||
env = "NESCOPE_XWAYLAND",
|
||||
num_args = 0..=1,
|
||||
default_value_t = false,
|
||||
default_missing_value = "true",
|
||||
value_parser = flag_value,
|
||||
)]
|
||||
xwayland: bool,
|
||||
|
||||
/// Wayland socket name (created in $XDG_RUNTIME_DIR).
|
||||
@@ -422,8 +451,7 @@ fn main() {
|
||||
// wrong way round, and it capped them at 60 while sessions asked for 120.
|
||||
// The capture layer holds the game instead, and this runs fast enough to
|
||||
// stay out of the way.
|
||||
let frame_interval =
|
||||
Duration::from_micros(1_000_000 / args.frame_callback_hz.max(1) as u64);
|
||||
let frame_interval = Duration::from_micros(1_000_000 / args.frame_callback_hz.max(1) as u64);
|
||||
loop_handle
|
||||
.insert_source(Timer::from_duration(frame_interval), move |_, _, data| {
|
||||
if let Some(ref mut li) = data.libinput {
|
||||
@@ -437,7 +465,7 @@ fn main() {
|
||||
// ── CalloopData ───────────────────────────────────────────────────────
|
||||
let socket_name_for_cleanup = args.socket.clone();
|
||||
let command = args.command.clone();
|
||||
let gamescope_wayland_socket = args.socket.clone();
|
||||
let wayland_socket = args.socket.clone();
|
||||
|
||||
// ── libinput backend ─────────────────────────────────────────────────
|
||||
let libinput_ctx =
|
||||
@@ -491,7 +519,7 @@ fn main() {
|
||||
// Put the game in its own process group so we can
|
||||
// kill the whole tree at once with kill(-pgid, …).
|
||||
.process_group(0)
|
||||
.env("WAYLAND_DISPLAY", &gamescope_wayland_socket);
|
||||
.env("WAYLAND_DISPLAY", &wayland_socket);
|
||||
|
||||
// DISPLAY only if XWayland is actually running. Setting it
|
||||
// otherwise points clients at a server that is not there,
|
||||
@@ -516,24 +544,6 @@ fn main() {
|
||||
// this. Without it neither DX11 nor DX12 (vkd3d-proton
|
||||
// through DXVK's dxgi) sees HDR as available.
|
||||
cmd.env("DXVK_HDR", "1");
|
||||
|
||||
// Left set, but deliberately without ENABLE_GAMESCOPE_WSI
|
||||
// alongside it, so it is inert unless somebody opts in.
|
||||
//
|
||||
// That pair activates gamescope's WSI layer, which
|
||||
// predates Wayland colour management and works by
|
||||
// hiding HDR from the driver and reporting it to the
|
||||
// compositor out of band. We do not want it: it needs a
|
||||
// layer this image does not ship, it only helps the
|
||||
// XWayland path, and capture reads the colour space it
|
||||
// hides -- measured, a game asking for HDR10 through it
|
||||
// has its PQ samples encoded and tagged BT.709 SDR.
|
||||
// Enabling it would trade no HDR for wrong HDR.
|
||||
tracing::debug!(
|
||||
gamescope_wayland_socket,
|
||||
"HDR: Wayland colour management; gamescope WSI not enabled"
|
||||
);
|
||||
cmd.env("GAMESCOPE_WAYLAND_DISPLAY", &gamescope_wayland_socket);
|
||||
}
|
||||
|
||||
// Detect GPU vendor from render device and set VK_DRIVER_FILES
|
||||
@@ -625,6 +635,12 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Answer any colour-management information requests that came in
|
||||
// this iteration. Deferred to here because the event that ends
|
||||
// them destroys the object, and doing that inside the request that
|
||||
// created it panics the backend -- see .
|
||||
data.state.hdr.flush_information();
|
||||
|
||||
// ── Flush Wayland clients ─────────────────────────────────
|
||||
if let Err(e) = data.display.flush_clients() {
|
||||
tracing::warn!("Error flushing Wayland clients: {e}");
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<protocol name="gamescope_swapchain">
|
||||
|
||||
<copyright>
|
||||
Copyright © 2023 Joshua Ashton for Valve Software
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a
|
||||
copy of this software and associated documentation files (the "Software"),
|
||||
to deal in the Software without restriction, including without limitation
|
||||
the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
||||
and/or sell copies of the Software, and to permit persons to whom the
|
||||
Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
||||
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
</copyright>
|
||||
|
||||
<description summary="gamescope-specific swapchain protocol">
|
||||
This is a private Gamescope protocol. Regular Wayland clients must not use
|
||||
it.
|
||||
</description>
|
||||
|
||||
<interface name="gamescope_swapchain_factory_v2" version="1">
|
||||
<request name="destroy" type="destructor"></request>
|
||||
|
||||
<request name="create_swapchain">
|
||||
<description summary="create Gamescope swapchain interface">
|
||||
</description>
|
||||
|
||||
<arg name="surface" type="object" interface="wl_surface"
|
||||
summary="target surface"/>
|
||||
<arg name="callback" type="new_id" interface="gamescope_swapchain"
|
||||
summary="new swapchain object"/>
|
||||
</request>
|
||||
</interface>
|
||||
|
||||
<interface name="gamescope_swapchain" version="1">
|
||||
<request name="destroy" type="destructor"></request>
|
||||
|
||||
<request name="override_window_content">
|
||||
<description summary="override an X11's window wl_surface">
|
||||
Xwayland creates a wl_surface for each X11 window. It sends a
|
||||
WL_SURFACE_ID client message to indicate the mapping between the X11
|
||||
windows and the wl_surface objects.
|
||||
|
||||
This request overrides this mapping for a given X11 window, allowing an
|
||||
X11 client to submit buffers via the Wayland protocol. The override
|
||||
only affects buffer submission. Everything else (e.g. input events)
|
||||
still uses Xwayland's WL_SURFACE_ID.
|
||||
|
||||
x11_server is gotten by the GAMESCOPE_XWAYLAND_SERVER_ID property on the
|
||||
root window of the associated server.
|
||||
</description>
|
||||
<arg name="gamescope_xwayland_server_id" type="uint" summary="gamescope xwayland server ID"/>
|
||||
<arg name="x11_window" type="uint" summary="X11 window ID"/>
|
||||
</request>
|
||||
|
||||
<request name="swapchain_feedback">
|
||||
<description summary="provide swapchain feedback">
|
||||
Provide swapchain feedback to the compositor.
|
||||
|
||||
This is what the useless tearing protocol should have been.
|
||||
Absolutely not enough information in the final protocol to do what we want for SteamOS --
|
||||
which is have the Allow Tearing toggle apply to *both* Mailbox + Immediate and NOT fifo,
|
||||
essentially acting as an override for tearing on/off for games.
|
||||
The upstream protocol is very useless for our usecase here.
|
||||
|
||||
Provides image count ahead of time instead of needing to try and calculate it from
|
||||
an initial stall if we are doing low latency.
|
||||
|
||||
Provides colorspace info for us to do HDR for both HDR10 PQ and scRGB.
|
||||
The upstream HDR efforts seem to have no interest in supporting scRGB but we *need* that so /shrug
|
||||
We can do it here now! Yipee!
|
||||
|
||||
Swapchain feedback solves so many problems! :D
|
||||
</description>
|
||||
<arg name="image_count" type="uint" summary="image count of swapchain"/>
|
||||
<arg name="vk_format" type="uint" summary="VkFormat of swapchain"/>
|
||||
<arg name="vk_colorspace" type="uint" summary="VkColorSpaceKHR of swapchain"/>
|
||||
<arg name="vk_composite_alpha" type="uint" summary="VkCompositeAlphaFlagBitsKHR of swapchain"/>
|
||||
<arg name="vk_pre_transform" type="uint" summary="VkSurfaceTransformFlagBitsKHR of swapchain"/>
|
||||
<arg name="vk_clipped" type="uint" summary="clipped (VkBool32) of swapchain"/>
|
||||
<arg name="vk_engine_name" type="string" summary="Engine name"/>
|
||||
</request>
|
||||
|
||||
<request name="set_present_mode">
|
||||
<description summary="Add a fifo queue constraint"/>
|
||||
<arg name="vk_present_mode" type="uint" summary="VkPresentModeKHR"/>
|
||||
</request>
|
||||
|
||||
<request name="set_hdr_metadata">
|
||||
<description summary="set HDR metadata for a surface">
|
||||
Forward HDR metadata from Vulkan to the compositor.
|
||||
|
||||
HDR Metadata Infoframe as per CTA 861.G spec.
|
||||
This is expected to match exactly with the spec.
|
||||
|
||||
display_primary_*:
|
||||
Color Primaries of the Data.
|
||||
Specifies X and Y coordinates.
|
||||
These are coded as unsigned 16-bit values in units of
|
||||
0.00002, where 0x0000 represents zero and 0xC350
|
||||
represents 1.0000.
|
||||
|
||||
white_point_*:
|
||||
White Point of Colorspace Data.
|
||||
Specifies X and Y coordinates.
|
||||
These are coded as unsigned 16-bit values in units of
|
||||
0.00002, where 0x0000 represents zero and 0xC350
|
||||
represents 1.0000.
|
||||
|
||||
max_display_mastering_luminance:
|
||||
Max Mastering Display Luminance.
|
||||
This value is coded as an unsigned 16-bit value in units of 1 cd/m2,
|
||||
where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2.
|
||||
|
||||
max_display_mastering_luminance:
|
||||
Min Mastering Display Luminance.
|
||||
This value is coded as an unsigned 16-bit value in units of
|
||||
0.0001 cd/m2, where 0x0001 represents 0.0001 cd/m2 and 0xFFFF
|
||||
represents 6.5535 cd/m2.
|
||||
|
||||
max_cll:
|
||||
Max Content Light Level.
|
||||
This value is coded as an unsigned 16-bit value in units of 1 cd/m2,
|
||||
where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2.
|
||||
|
||||
max_fall:
|
||||
Max Frame Average Light Level.
|
||||
This value is coded as an unsigned 16-bit value in units of 1 cd/m2,
|
||||
where 0x0001 represents 1 cd/m2 and 0xFFFF represents 65535 cd/m2.
|
||||
</description>
|
||||
<arg name="display_primary_red_x" type="uint" summary="red primary x coordinate"/>
|
||||
<arg name="display_primary_red_y" type="uint" summary="red primary y coordinate"/>
|
||||
<arg name="display_primary_green_x" type="uint" summary="green primary x coordinate"/>
|
||||
<arg name="display_primary_green_y" type="uint" summary="green primary y coordinate"/>
|
||||
<arg name="display_primary_blue_x" type="uint" summary="blue primary x coordinate"/>
|
||||
<arg name="display_primary_blue_y" type="uint" summary="blue primary y coordinate"/>
|
||||
<arg name="white_point_x" type="uint" summary="white point x coordinate"/>
|
||||
<arg name="white_point_y" type="uint" summary="white point y coordinate"/>
|
||||
<arg name="max_display_mastering_luminance" type="uint" summary="max display mastering luminance"/>
|
||||
<arg name="min_display_mastering_luminance" type="uint" summary="min display mastering luminance"/>
|
||||
<arg name="max_cll" type="uint" summary="max content light level"/>
|
||||
<arg name="max_fall" type="uint" summary="max frame average light level"/>
|
||||
</request>
|
||||
|
||||
<request name="set_present_time">
|
||||
<description summary="display timing of next commit">
|
||||
Sets the display timing of the next commit.
|
||||
|
||||
This gets reset to 0s in the compositor's state after a commit.
|
||||
</description>
|
||||
<arg name="present_id" type="uint" summary="application provided presentation id"/>
|
||||
<arg name="desired_present_time_hi" type="uint" summary="high part of the desired presentation time for this commit. Uses CLOCK_MONOTONIC. 0 = present as soon as possible."/>
|
||||
<arg name="desired_present_time_lo" type="uint" summary="low part of the desired presentation time for this commit. Uses CLOCK_MONOTONIC. 0 = present as soon as possible."/>
|
||||
</request>
|
||||
|
||||
<event name="past_present_timing">
|
||||
<description summary="information about past presentation">
|
||||
Gives information on the past presentation timing
|
||||
</description>
|
||||
<arg name="present_id" type="uint" summary="application provided presentation id"/>
|
||||
<arg name="desired_present_time_hi" type="uint" summary="high part of the desired presentation time for the commit. (from the app)"/>
|
||||
<arg name="desired_present_time_lo" type="uint" summary="low part of the desired presentation time for the commit. (from the app)"/>
|
||||
<arg name="actual_present_time_hi" type="uint" summary="high part of the actual present time for this commit."/>
|
||||
<arg name="actual_present_time_lo" type="uint" summary="low part of the actual present time for this commit."/>
|
||||
<arg name="earliest_present_time_hi" type="uint" summary="high part of the refresh time that Gamescope thought this commit was done by."/>
|
||||
<arg name="earliest_present_time_lo" type="uint" summary="low part of the refresh time that Gamescope thought this commit was done by."/>
|
||||
<arg name="present_margin_hi" type="uint" summary="high part of the difference between earliest present time and the earliest latch time"/>
|
||||
<arg name="present_margin_lo" type="uint" summary="low part of the difference between earliest present time and the earliest latch time"/>
|
||||
</event>
|
||||
|
||||
<event name="refresh_cycle">
|
||||
<description summary="information about refresh cycle for this swapchain">
|
||||
Gives information on the refresh cycle for this swapchain
|
||||
</description>
|
||||
<arg name="refresh_cycle_hi" type="uint" summary="high part of the refresh cycle in nanos"/>
|
||||
<arg name="refresh_cycle_lo" type="uint" summary="low part of the refresh cycle in nanos"/>
|
||||
</event>
|
||||
|
||||
<event name="retired">
|
||||
<description summary="Swapchain was remotely retired"></description>
|
||||
</event>
|
||||
</interface>
|
||||
</protocol>
|
||||
@@ -1,16 +0,0 @@
|
||||
//! Generated Wayland protocol bindings for the gamescope swapchain protocol.
|
||||
|
||||
#![allow(non_upper_case_globals, non_camel_case_types, unused)]
|
||||
|
||||
use smithay::reexports::wayland_server;
|
||||
use wayland_server::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use super::wayland_server;
|
||||
use wayland_server::backend as wayland_backend;
|
||||
use wayland_server::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("src/protocols/gamescope-swapchain.xml");
|
||||
}
|
||||
|
||||
use self::__interfaces::*;
|
||||
wayland_scanner::generate_server_code!("src/protocols/gamescope-swapchain.xml");
|
||||
+27
-41
@@ -24,6 +24,7 @@ use calloop::channel::Sender;
|
||||
use smithay::desktop::utils::{
|
||||
OutputPresentationFeedback, send_frames_surface_tree,
|
||||
surface_presentation_feedback_flags_from_states, surface_primary_scanout_output,
|
||||
take_presentation_feedback_surface_tree,
|
||||
};
|
||||
use smithay::desktop::{Space, Window};
|
||||
use smithay::input::pointer::CursorImageStatus;
|
||||
@@ -96,13 +97,18 @@ impl ClientData for ClientState {
|
||||
// X11 atoms + connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Gamescope-compatible X11 atoms used for focus and HDR signalling.
|
||||
/// Gamescope-compatible X11 atoms, for focus.
|
||||
///
|
||||
/// Focus only. HDR used to be signalled through one of these as well, back
|
||||
/// when a WSI layer inside the game reported the colour space out of band;
|
||||
/// that route is gone and `wp_color_manager_v1` carries it. These remain
|
||||
/// because Steam reads them to work out which window it is looking at, and
|
||||
/// that is unrelated to colour.
|
||||
pub struct CachedAtoms {
|
||||
pub net_active_window: u32,
|
||||
pub gamescope_focused_app: u32,
|
||||
pub gamescope_focusable_apps: u32,
|
||||
pub gamescope_focusable_windows: u32,
|
||||
pub gamescope_hdr_output_feedback: u32,
|
||||
pub gamescope_xwayland_server_id: u32,
|
||||
pub xa_window: u32,
|
||||
pub xa_cardinal: u32,
|
||||
@@ -157,10 +163,6 @@ pub struct NescopeState {
|
||||
pub focused_app_id: u32,
|
||||
/// True when X11 focus needs to be re-synced on the next input event.
|
||||
pub x11_focus_needs_reset: bool,
|
||||
/// Gamescope WSI override surface (direct Vulkan → Wayland bypass).
|
||||
pub override_surface: Option<WlSurface>,
|
||||
/// Surfaces that have announced themselves as Vulkan via gamescope protocol.
|
||||
pub vulkan_surfaces: HashSet<WlSurface>,
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────
|
||||
/// Sender half of the input channel — clone and hand to callers.
|
||||
@@ -267,7 +269,7 @@ impl NescopeState {
|
||||
let (dmabuf_state, dmabuf_global) =
|
||||
build_dmabuf_global::<Self>(&display_handle, render_device.as_deref());
|
||||
|
||||
// HDR + gamescope swapchain globals (optional).
|
||||
// Colour management, when asked for.
|
||||
let hdr_state = HdrState::new(&display_handle, hdr);
|
||||
|
||||
// Input channel — the Sender is returned to the caller.
|
||||
@@ -309,8 +311,6 @@ impl NescopeState {
|
||||
focused_x11_window: None,
|
||||
focused_app_id: 0,
|
||||
x11_focus_needs_reset: false,
|
||||
override_surface: None,
|
||||
vulkan_surfaces: HashSet::new(),
|
||||
input_tx: input_tx.clone(),
|
||||
cursor_position: Point::from((0.0f64, 0.0f64)),
|
||||
cursor_status: CursorImageStatus::default_named(),
|
||||
@@ -398,10 +398,6 @@ impl NescopeState {
|
||||
gamescope_focused_app: intern_atom(&conn, b"GAMESCOPE_FOCUSED_APP"),
|
||||
gamescope_focusable_apps: intern_atom(&conn, b"GAMESCOPE_FOCUSABLE_APPS"),
|
||||
gamescope_focusable_windows: intern_atom(&conn, b"GAMESCOPE_FOCUSABLE_WINDOWS"),
|
||||
gamescope_hdr_output_feedback: intern_atom(
|
||||
&conn,
|
||||
b"GAMESCOPE_HDR_OUTPUT_FEEDBACK",
|
||||
),
|
||||
gamescope_xwayland_server_id: intern_atom(
|
||||
&conn,
|
||||
b"GAMESCOPE_XWAYLAND_SERVER_ID",
|
||||
@@ -422,8 +418,10 @@ impl NescopeState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write gamescope-specific X11 root window properties so the WSI layer
|
||||
/// can discover this compositor as a gamescope-compatible server.
|
||||
/// Write the gamescope-compatible X11 root window properties.
|
||||
///
|
||||
/// These say which application has focus and what could take it, which is
|
||||
/// what Steam looks for. Nothing here concerns colour.
|
||||
pub fn set_gamescope_atoms(
|
||||
&self,
|
||||
conn: &smithay::reexports::x11rb::rust_connection::RustConnection,
|
||||
@@ -438,14 +436,6 @@ impl NescopeState {
|
||||
let replace = PropMode::REPLACE;
|
||||
let cardinal = AtomEnum::CARDINAL;
|
||||
|
||||
// HDR output feedback — set to 1 when HDR is active.
|
||||
let _ = conn.change_property32(
|
||||
replace,
|
||||
root,
|
||||
atoms.gamescope_hdr_output_feedback,
|
||||
cardinal,
|
||||
&[1u32],
|
||||
);
|
||||
// XWayland server ID — always 0 for a standalone compositor.
|
||||
let _ = conn.change_property32(
|
||||
replace,
|
||||
@@ -465,16 +455,6 @@ impl NescopeState {
|
||||
tracing::debug!("Set gamescope atoms on display :{display_number}");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Override surface (gamescope WSI bypass)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Register the gamescope WSI override surface for an X11 window.
|
||||
pub fn override_window_surface(&mut self, x11_window: u32, surface: WlSurface) {
|
||||
tracing::debug!(x11_window, "Registered gamescope WSI override surface");
|
||||
self.override_surface = Some(surface);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Resize
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -769,12 +749,24 @@ impl NescopeState {
|
||||
// 1. Release the held buffer → frees a swapchain image for the game.
|
||||
self.held_buffer.take();
|
||||
|
||||
// 2. Presentation feedback — tell clients about vsync timing.
|
||||
// 2. Presentation feedback — every frame committed since the last tick
|
||||
// is reported presented, on the one output there is.
|
||||
//
|
||||
// Not filtered by primary scan-out output: that is recorded by a
|
||||
// renderer, and nothing here renders, so the filter matched no
|
||||
// surface ever. Feedback then resolved only as `discarded`, when the
|
||||
// next commit superseded it -- which never happens for a client that
|
||||
// waits for its last present before drawing the next. A Vulkan
|
||||
// client with present-wait under FIFO does exactly that: Control on
|
||||
// VKD3D-Proton froze on leaving its title screen, GPU idle, the game
|
||||
// still running behind a stream that no longer moved.
|
||||
let mut output_presentation_feedback = OutputPresentationFeedback::new(&output);
|
||||
let on_output =
|
||||
|_: &WlSurface, _: &smithay::wayland::compositor::SurfaceData| Some(output.clone());
|
||||
for window in self.space.elements().cloned().collect::<Vec<_>>() {
|
||||
window.take_presentation_feedback(
|
||||
&mut output_presentation_feedback,
|
||||
surface_primary_scanout_output,
|
||||
on_output,
|
||||
|_, _| wp_presentation_feedback::Kind::Vsync,
|
||||
);
|
||||
}
|
||||
@@ -797,12 +789,6 @@ impl NescopeState {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref s) = self.override_surface {
|
||||
send_frames_surface_tree(s, &output, now, Some(Duration::ZERO), |_, _| {
|
||||
Some(output.clone())
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Send periodic stats over IPC
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(self.last_stats_time) >= std::time::Duration::from_secs(1) {
|
||||
|
||||
@@ -11,7 +11,7 @@ name = "neshub"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
iroh = "1.1.0"
|
||||
iroh = "1.2"
|
||||
|
||||
anyhow.workspace = true
|
||||
clap.workspace = true
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
//! Deciding what the encoder may spend.
|
||||
//!
|
||||
//! # Why this exists at all
|
||||
//!
|
||||
//! Nothing used to decide. The encoder took a bitrate from an environment
|
||||
//! variable nobody set, so every session offered the same 10 Mbps whatever the
|
||||
//! path between the two ends could carry. On a path sustaining under 3 Mbps that
|
||||
//! does not degrade the picture, it removes it: most fragments are dropped,
|
||||
//! every frame made of several fragments arrives incomplete, and the keyframe --
|
||||
//! the largest frame there is -- never completes either, so nothing ever
|
||||
//! recovers.
|
||||
//!
|
||||
//! # Why the receiver decides, and not this end
|
||||
//!
|
||||
//! The obvious inputs are here: the hub holds the QUIC connection and can read
|
||||
//! its round-trip time, its congestion window and its remaining datagram buffer.
|
||||
//! They were measured, over an entire multi-session run, saying the path was
|
||||
//! healthy while the client was receiving almost nothing -- 182 ms round trip,
|
||||
//! *zero* QUIC packet loss, and arrivals falling from 1776 to 239 datagrams a
|
||||
//! second.
|
||||
//!
|
||||
//! Part of that is structural rather than bad luck. `send_datagram` evicts the
|
||||
//! oldest queued datagrams and returns `Ok`, so a sender that is overrunning the
|
||||
//! path is told nothing at all; the "frames dropped" counter it feeds cannot be
|
||||
//! non-zero however badly things are going. So the primary evidence is the
|
||||
//! receiver's own report of what arrived, and this end's view is the fallback
|
||||
//! for when no report has come in.
|
||||
//!
|
||||
//! # Shape
|
||||
//!
|
||||
//! Slow and dull on purpose. This is not a congestion controller in the QUIC
|
||||
//! sense and does not try to be: it finds the right order of magnitude for a
|
||||
//! video bitrate and stays there, at one decision a second. The transport
|
||||
//! underneath is still doing real congestion control on its own packets.
|
||||
|
||||
use nesprotocol::{ControlMode, ReceiverReport};
|
||||
|
||||
/// Loss above this means the path is being overrun and the target comes down.
|
||||
const LOSS_DECREASE: f32 = 0.10;
|
||||
/// Loss below this means there is room to climb.
|
||||
const LOSS_INCREASE: f32 = 0.02;
|
||||
|
||||
/// What to keep of the measured goodput when backing off.
|
||||
///
|
||||
/// Backing off to *below* what actually got through, rather than to a fraction
|
||||
/// of what we were asking for, is what makes recovery quick. At 10 Mbps into a
|
||||
/// 3 Mbps path the next target is about 2.5 Mbps rather than 8.5, so one step
|
||||
/// does what twenty multiplicative decreases would.
|
||||
const BACKOFF: f32 = 0.85;
|
||||
|
||||
/// How much of the ceiling to add per calm second. Twenty steps from floor to
|
||||
/// ceiling: slow enough that a brief quiet spell does not undo a back-off.
|
||||
const CLIMB_FRACTION: u32 = 20;
|
||||
|
||||
/// Don't actuate for less than this fraction of the current target.
|
||||
///
|
||||
/// Retuning is cheap now -- it no longer rebuilds the encoder or forces a
|
||||
/// keyframe -- but a line in the log every second saying the bitrate moved by
|
||||
/// 1% is noise that hides the changes that matter.
|
||||
const DEADBAND: f32 = 0.10;
|
||||
|
||||
/// Never go below this, whatever the ceiling is.
|
||||
///
|
||||
/// Under this a 1080p stream is not worth sending and the right answer is fewer
|
||||
/// pixels rather than fewer bits, which is a decision about the tier and not one
|
||||
/// this can make. It also has to leave room for audio, which shares the
|
||||
/// connection and is not counted against the video ceiling.
|
||||
const ABSOLUTE_FLOOR_KBPS: u32 = 800;
|
||||
|
||||
/// Seconds without a receiver report before this end's own view is used instead.
|
||||
const SILENT_TICKS_BEFORE_FALLBACK: u32 = 3;
|
||||
|
||||
/// Backlog, in milliseconds of video, that means the path is being overrun.
|
||||
///
|
||||
/// Not a tuned number: it is a latency budget. At sixty frames a second a frame
|
||||
/// is under 17 ms, so a quarter second of backlog is fifteen frames already
|
||||
/// handed over and not yet gone -- a player is looking at a picture from before
|
||||
/// their last four keypresses. There is no bitrate worth that, so past this the
|
||||
/// target comes down whatever the loss says.
|
||||
const QUEUE_DECREASE_MS: u32 = 250;
|
||||
|
||||
/// Backlog, in milliseconds, under which the path is considered clear.
|
||||
///
|
||||
/// Climbing needs a stronger warrant than holding does, because climbing is
|
||||
/// what digs the queue. Five frames or so of backlog is the most that can be
|
||||
/// outstanding and still be called live.
|
||||
const QUEUE_CLIMB_MS: u32 = 80;
|
||||
|
||||
/// The band a target must stay inside.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
pub ceiling_kbps: u32,
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
pub fn new(ceiling_kbps: u32) -> Self {
|
||||
Self { ceiling_kbps }
|
||||
}
|
||||
|
||||
/// The lowest this may go.
|
||||
pub fn floor_kbps(&self) -> u32 {
|
||||
ABSOLUTE_FLOOR_KBPS
|
||||
.max(self.ceiling_kbps / 10)
|
||||
// A ceiling below the floor is a tier nobody should have configured,
|
||||
// but clamping the wrong way round would put the target *above* the
|
||||
// ceiling, which is the one thing a ceiling must never allow.
|
||||
.min(self.ceiling_kbps)
|
||||
}
|
||||
|
||||
fn clamp(&self, kbps: u32) -> u32 {
|
||||
kbps.clamp(self.floor_kbps(), self.ceiling_kbps)
|
||||
}
|
||||
}
|
||||
|
||||
/// What this end can see about the path, for when the far end is not talking.
|
||||
///
|
||||
/// Every field is optional because every one of them can be unavailable: a
|
||||
/// connection with no established path has no round-trip time, and a congestion
|
||||
/// window is only meaningful once something has been sent.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PathView {
|
||||
/// Congestion window in bytes, as the transport currently believes it.
|
||||
pub cwnd_bytes: Option<u64>,
|
||||
/// Round-trip time in milliseconds.
|
||||
pub rtt_ms: Option<u32>,
|
||||
/// Datagram bytes handed to the transport that have not yet left.
|
||||
///
|
||||
/// The backlog is the one thing this end can see that says the path is
|
||||
/// being overrun *while it is still only late*. Loss says so too, but only
|
||||
/// afterwards, and on a path that queues rather than drops, "afterwards"
|
||||
/// can be several seconds.
|
||||
pub backlog_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl PathView {
|
||||
/// How long the backlog takes to drain at `drain_kbps`, in milliseconds.
|
||||
///
|
||||
/// Bits divided by kilobits-per-second is milliseconds. The rate to divide
|
||||
/// by is the one the queue actually drains at -- what is getting through --
|
||||
/// and not what we are asking for, which is the number that is too high
|
||||
/// whenever this matters.
|
||||
pub fn backlog_ms(&self, drain_kbps: u32) -> Option<u32> {
|
||||
let backlog = self.backlog_bytes?;
|
||||
let drain = u64::from(drain_kbps.max(1));
|
||||
Some((backlog.saturating_mul(8) / drain).min(u64::from(u32::MAX)) as u32)
|
||||
}
|
||||
|
||||
/// Delivery rate the window and round trip imply, in kbps.
|
||||
///
|
||||
/// A window is a quantity of bytes in flight for one round trip, so the two
|
||||
/// together are a rate. Treated as an estimate of last resort: it describes
|
||||
/// the transport's own opinion of the path, and that opinion is exactly what
|
||||
/// was observed to be wrong when this mattered most.
|
||||
pub fn estimate_kbps(&self) -> Option<u32> {
|
||||
let (cwnd, rtt) = (self.cwnd_bytes?, self.rtt_ms?.max(1));
|
||||
let bits_per_second = cwnd.saturating_mul(8) * 1000 / u64::from(rtt);
|
||||
Some((bits_per_second / 1000).min(u64::from(u32::MAX)) as u32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the target is what it is, for the overlay and the log.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum Reason {
|
||||
/// Loss was high; backed off to under what was actually getting through.
|
||||
Congested,
|
||||
/// Loss was in the middle band; left alone.
|
||||
Holding,
|
||||
/// Loss was low; climbing towards the ceiling.
|
||||
Climbing,
|
||||
/// No receiver report recently; using this end's view of the path.
|
||||
Fallback,
|
||||
/// No report and no usable view either; decaying towards the floor.
|
||||
Blind,
|
||||
/// Somebody set the bitrate by hand.
|
||||
Manual,
|
||||
/// The encoder is not under a bitrate at all, so there is nothing to decide.
|
||||
ConstantQuality,
|
||||
/// Nobody is connected, so there is no path to have an opinion about.
|
||||
NoClients,
|
||||
/// This second's numbers describe frames the hub chose not to send, so they
|
||||
/// say nothing about the path.
|
||||
SelfInflicted,
|
||||
/// Nothing was lost, but the send queue is standing deep enough that the
|
||||
/// picture is arriving late. Backed off on the queue rather than on loss.
|
||||
Backlogged,
|
||||
}
|
||||
|
||||
/// The controller's whole state.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Controller {
|
||||
/// The ceiling the box was given, which a client may lower but never raise.
|
||||
///
|
||||
/// Kept apart from `limits`, which holds whatever is in force *now*. Folding
|
||||
/// the two together means a client that lowers the ceiling can never raise
|
||||
/// it again, because the only number left to compare against is the one it
|
||||
/// just lowered.
|
||||
box_ceiling_kbps: u32,
|
||||
/// What the controller currently believes the bitrate should be.
|
||||
///
|
||||
/// Kept apart from `sent_kbps` deliberately. Folding the two together looks
|
||||
/// simpler and stalls the climb: the step up is a fraction of the *ceiling*,
|
||||
/// so once the target passes half of it each step is under the deadband, and
|
||||
/// a target that only moved when it actuated could never accumulate past
|
||||
/// that point. Belief moves every second; only saying so is rationed.
|
||||
target_kbps: u32,
|
||||
/// What the encoder was last told, or `None` before anything was sent.
|
||||
sent_kbps: Option<u32>,
|
||||
limits: Limits,
|
||||
mode: ControlMode,
|
||||
/// Set when the encoder is in constant-QP; there is no bitrate to control.
|
||||
constant_quality: bool,
|
||||
silent_ticks: u32,
|
||||
reason: Reason,
|
||||
/// Last measured send-queue depth, kept for reporting rather than control.
|
||||
backlog_ms: u32,
|
||||
}
|
||||
|
||||
impl Controller {
|
||||
/// Start at the ceiling.
|
||||
///
|
||||
/// Optimistic on purpose: the common case is a path that can carry the tier
|
||||
/// that was sold, and a session that opened at its floor and climbed would
|
||||
/// take twenty seconds to look like what was paid for. The first bad report
|
||||
/// undoes it in one step.
|
||||
pub fn new(limits: Limits) -> Self {
|
||||
Self {
|
||||
box_ceiling_kbps: limits.ceiling_kbps,
|
||||
target_kbps: limits.ceiling_kbps,
|
||||
sent_kbps: None,
|
||||
limits,
|
||||
mode: ControlMode::Auto,
|
||||
constant_quality: false,
|
||||
silent_ticks: 0,
|
||||
reason: Reason::Holding,
|
||||
backlog_ms: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn target_kbps(&self) -> u32 {
|
||||
self.target_kbps
|
||||
}
|
||||
pub fn limits(&self) -> Limits {
|
||||
self.limits
|
||||
}
|
||||
pub fn mode(&self) -> ControlMode {
|
||||
self.mode
|
||||
}
|
||||
pub fn reason(&self) -> Reason {
|
||||
self.reason
|
||||
}
|
||||
|
||||
/// How far behind the send queue was at the last decision, in milliseconds.
|
||||
pub fn backlog_ms(&self) -> u32 {
|
||||
self.backlog_ms
|
||||
}
|
||||
|
||||
/// The ceiling the box was given, whatever a client has since asked for.
|
||||
pub fn box_ceiling_kbps(&self) -> u32 {
|
||||
self.box_ceiling_kbps
|
||||
}
|
||||
|
||||
/// Adopt a ceiling a client asked for, never above the one the box was given.
|
||||
///
|
||||
/// A client may lower its own ceiling -- to test a path, or because it knows
|
||||
/// something about its link that this end does not -- but it may not raise
|
||||
/// the one the tier bought.
|
||||
pub fn set_ceiling(&mut self, ceiling_kbps: u32) {
|
||||
self.limits = Limits::new(ceiling_kbps.min(self.box_ceiling_kbps).max(1));
|
||||
self.target_kbps = self.limits.clamp(self.target_kbps);
|
||||
// A ceiling that moved is worth restating even when the target did not,
|
||||
// because the encoder is the thing that has to hear about it.
|
||||
self.sent_kbps = None;
|
||||
}
|
||||
|
||||
pub fn set_mode(&mut self, mode: ControlMode) {
|
||||
self.mode = mode;
|
||||
if mode == ControlMode::Manual {
|
||||
self.reason = Reason::Manual;
|
||||
}
|
||||
}
|
||||
|
||||
/// Note that somebody set the bitrate by hand.
|
||||
pub fn note_manual_target(&mut self, kbps: u32) {
|
||||
self.target_kbps = kbps;
|
||||
self.sent_kbps = Some(kbps);
|
||||
self.mode = ControlMode::Manual;
|
||||
self.reason = Reason::Manual;
|
||||
}
|
||||
|
||||
/// Note that the encoder is, or is no longer, under constant quality.
|
||||
pub fn set_constant_quality(&mut self, constant_quality: bool) {
|
||||
self.constant_quality = constant_quality;
|
||||
}
|
||||
|
||||
/// One second's decision.
|
||||
///
|
||||
/// `report` is the worst report across the clients attached, or `None` when
|
||||
/// none of them said anything. Returns the new target when it is worth
|
||||
/// sending, and `None` when nothing should be sent -- which is most seconds.
|
||||
/// One second's decision.
|
||||
///
|
||||
/// `self_inflicted` says this second contained frames the hub deliberately
|
||||
/// withheld -- a client resynchronising, whose deltas were undecodable and
|
||||
/// were dropped rather than sent. Those seconds cannot be read as evidence
|
||||
/// about the path: fewer frames were sent, so fewer arrived, so the measured
|
||||
/// goodput is low and the loss is high, and a controller anchoring a backoff
|
||||
/// on that would cut the bitrate in response to its own decision. Every
|
||||
/// stutter would then also cost bandwidth, which is the opposite of what a
|
||||
/// resynchronisation needs.
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
clients: usize,
|
||||
report: Option<ReceiverReport>,
|
||||
path: PathView,
|
||||
self_inflicted: bool,
|
||||
) -> Option<u32> {
|
||||
if clients == 0 {
|
||||
// Nothing is connected, so nothing is being carried and there is no
|
||||
// path to form an opinion about. Deciding here means deciding on the
|
||||
// absence of evidence: the controller used to read it as silence and
|
||||
// decay to the floor, so a box waiting for its first client spent
|
||||
// that time winding itself down and then jumped back up the moment
|
||||
// somebody arrived.
|
||||
self.reason = Reason::NoClients;
|
||||
self.silent_ticks = 0;
|
||||
return None;
|
||||
}
|
||||
if self.constant_quality {
|
||||
self.reason = Reason::ConstantQuality;
|
||||
return None;
|
||||
}
|
||||
if self.mode == ControlMode::Manual {
|
||||
self.reason = Reason::Manual;
|
||||
return None;
|
||||
}
|
||||
if self_inflicted {
|
||||
// Held, not decayed. The path may be fine; this second simply
|
||||
// cannot say, and silence about the path is not evidence against it.
|
||||
self.reason = Reason::SelfInflicted;
|
||||
self.silent_ticks = 0;
|
||||
return None;
|
||||
}
|
||||
|
||||
// What the queue drains at is what is getting through. With no report
|
||||
// to say, the target is the best guess available -- and a target that
|
||||
// is too high only makes the backlog look shorter than it is, so this
|
||||
// errs towards patience rather than towards cutting the rate.
|
||||
let drain_kbps = match report.as_ref().map(|r| (r.goodput_bps / 1000) as u32) {
|
||||
Some(measured) if measured > 0 => measured,
|
||||
_ => self.target_kbps,
|
||||
};
|
||||
self.backlog_ms = path.backlog_ms(drain_kbps).unwrap_or(0);
|
||||
|
||||
let next = match report.as_ref().and_then(|r| r.loss().map(|l| (r, l))) {
|
||||
Some((report, loss)) => {
|
||||
self.silent_ticks = 0;
|
||||
self.decide_from_report(report, loss, path)
|
||||
}
|
||||
None => {
|
||||
self.silent_ticks = self.silent_ticks.saturating_add(1);
|
||||
if self.silent_ticks < SILENT_TICKS_BEFORE_FALLBACK {
|
||||
self.reason = Reason::Holding;
|
||||
return None;
|
||||
}
|
||||
self.decide_from_path(path)
|
||||
}
|
||||
};
|
||||
|
||||
self.target_kbps = self.limits.clamp(next);
|
||||
// Every second moves the belief; only a change worth acting on is said.
|
||||
// The first decision is always said -- the encoder started at whatever
|
||||
// its environment gave it, and this end has no way to know it matches.
|
||||
match self.sent_kbps {
|
||||
Some(sent) if !worth_sending(sent, self.target_kbps, self.limits) => None,
|
||||
_ => {
|
||||
self.sent_kbps = Some(self.target_kbps);
|
||||
Some(self.target_kbps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decide_from_report(&mut self, report: &ReceiverReport, loss: f32, path: PathView) -> u32 {
|
||||
let measured = (report.goodput_bps / 1000) as u32;
|
||||
// `backlog_ms` is zero both when the queue is empty and when the
|
||||
// transport cannot say, which are the same thing for this purpose: a
|
||||
// queue nobody can see is not evidence of one.
|
||||
let known = path.backlog_bytes.is_some();
|
||||
let queued = known && self.backlog_ms > QUEUE_DECREASE_MS;
|
||||
|
||||
if loss > LOSS_DECREASE || queued {
|
||||
self.reason = if queued {
|
||||
Reason::Backlogged
|
||||
} else {
|
||||
Reason::Congested
|
||||
};
|
||||
// Anchored on what actually arrived, not on what we were asking for.
|
||||
// `goodput` of zero means nothing completed at all, in which case
|
||||
// there is no measurement to anchor to and the target is simply
|
||||
// halved -- the alternative, backing off to zero, would take the
|
||||
// stream below the floor on a single bad second.
|
||||
let anchor = if measured == 0 {
|
||||
self.target_kbps / 2
|
||||
} else {
|
||||
measured.min(self.target_kbps)
|
||||
};
|
||||
return (anchor as f32 * BACKOFF) as u32;
|
||||
}
|
||||
// Climbing is what digs the queue, so it needs the queue to be empty as
|
||||
// well as the loss to be low. Without this the controller climbs all
|
||||
// the way to the ceiling against a path it is already overrunning,
|
||||
// because a path that queues instead of dropping reports no loss at all
|
||||
// until the buffer finally overflows -- and by then the picture is
|
||||
// seconds behind.
|
||||
if loss < LOSS_INCREASE && (!known || self.backlog_ms < QUEUE_CLIMB_MS) {
|
||||
self.reason = Reason::Climbing;
|
||||
return self
|
||||
.target_kbps
|
||||
.saturating_add(self.limits.ceiling_kbps / CLIMB_FRACTION);
|
||||
}
|
||||
self.reason = Reason::Holding;
|
||||
self.target_kbps
|
||||
}
|
||||
|
||||
fn decide_from_path(&mut self, path: PathView) -> u32 {
|
||||
match path.estimate_kbps() {
|
||||
Some(estimate) => {
|
||||
self.reason = Reason::Fallback;
|
||||
estimate
|
||||
}
|
||||
None => {
|
||||
// Nothing said and nothing visible. Holding a high target on no
|
||||
// evidence is how the original failure sustained itself, so this
|
||||
// decays rather than holds.
|
||||
self.reason = Reason::Blind;
|
||||
(self.target_kbps as f32 * BACKOFF) as u32
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a change is big enough to be worth actuating.
|
||||
///
|
||||
/// Reaching either end of the band always is: a target pinned to the floor or
|
||||
/// the ceiling is a fact worth stating even when the step to it was small.
|
||||
pub fn worth_sending(current: u32, next: u32, limits: Limits) -> bool {
|
||||
if current == next {
|
||||
return false;
|
||||
}
|
||||
if next == limits.floor_kbps() || next == limits.ceiling_kbps {
|
||||
return true;
|
||||
}
|
||||
let change = current.abs_diff(next) as f32;
|
||||
change >= current as f32 * DEADBAND
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const CEILING: u32 = 10_000;
|
||||
|
||||
fn limits() -> Limits {
|
||||
Limits::new(CEILING)
|
||||
}
|
||||
|
||||
fn controller() -> Controller {
|
||||
Controller::new(limits())
|
||||
}
|
||||
|
||||
/// A second in which `released` of `total` frames made it, carrying
|
||||
/// `goodput_kbps` of completed video.
|
||||
fn report(released: u32, incomplete: u32, goodput_kbps: u64) -> ReceiverReport {
|
||||
ReceiverReport {
|
||||
goodput_bps: goodput_kbps * 1000,
|
||||
released,
|
||||
incomplete,
|
||||
never_arrived: 0,
|
||||
rtt_ms: 180,
|
||||
}
|
||||
}
|
||||
|
||||
fn healthy() -> ReceiverReport {
|
||||
report(60, 0, 9_800)
|
||||
}
|
||||
|
||||
/// A path with `backlog` bytes handed over and not yet gone.
|
||||
fn backlogged(backlog: u64) -> PathView {
|
||||
PathView {
|
||||
cwnd_bytes: Some(13_000),
|
||||
rtt_ms: Some(180),
|
||||
backlog_bytes: Some(backlog),
|
||||
}
|
||||
}
|
||||
|
||||
/// The second reported failure, as measured over a 1000-mile link.
|
||||
///
|
||||
/// Every frame arrived and every frame completed -- zero loss, zero QUIC
|
||||
/// congestion events, a flat 180 ms round trip for the whole session -- and
|
||||
/// the picture was still several seconds behind, because the offer was
|
||||
/// several times what the path carried and the difference was sitting in
|
||||
/// the hub's own send buffer. A controller reading loss alone sees a
|
||||
/// perfect path here and climbs to the ceiling against it.
|
||||
#[test]
|
||||
fn a_path_that_queues_instead_of_dropping_must_not_read_as_healthy() {
|
||||
let mut c = controller();
|
||||
// Two megabits get through; a megabyte is already queued behind them.
|
||||
let arriving = report(60, 0, 2_000);
|
||||
let path = backlogged(1024 * 1024);
|
||||
|
||||
for _ in 0..30 {
|
||||
c.tick(1, Some(arriving), path, false);
|
||||
}
|
||||
|
||||
assert_ne!(
|
||||
c.reason(),
|
||||
Reason::Climbing,
|
||||
"climbing against a path with four seconds of backlog"
|
||||
);
|
||||
assert!(
|
||||
c.target_kbps() < 2_000,
|
||||
"target {} kbps is at or above what is getting through, so the \
|
||||
queue can only grow",
|
||||
c.target_kbps()
|
||||
);
|
||||
}
|
||||
|
||||
/// The sawtooth: floor, climb to ceiling, collapse, repeat every 20 s.
|
||||
///
|
||||
/// Loss alone cannot break this cycle, because on a queueing path loss only
|
||||
/// appears once the buffer finally overflows -- long after the latency has
|
||||
/// made the session unplayable, and by then the queue is deep enough that
|
||||
/// backing off to the floor is the only way out.
|
||||
#[test]
|
||||
fn the_target_settles_instead_of_sawtoothing() {
|
||||
let mut c = controller();
|
||||
// A steady 2 Mbps path. The backlog is what the last second of
|
||||
// over-sending left behind, drained at what actually gets through.
|
||||
let mut backlog: i64 = 0;
|
||||
let mut seen = Vec::new();
|
||||
|
||||
for _ in 0..60 {
|
||||
let target = c.target_kbps();
|
||||
// Whatever was asked for above 2 Mbps piles up; the rest drains.
|
||||
backlog = (backlog + (i64::from(target) - 2_000) * 1000 / 8).clamp(0, 4 * 1024 * 1024);
|
||||
c.tick(
|
||||
1,
|
||||
Some(report(60, 0, 2_000)),
|
||||
backlogged(backlog as u64),
|
||||
false,
|
||||
);
|
||||
seen.push(c.target_kbps());
|
||||
}
|
||||
|
||||
let settled = &seen[30..];
|
||||
let (lo, hi) = (
|
||||
*settled.iter().min().unwrap(),
|
||||
*settled.iter().max().unwrap(),
|
||||
);
|
||||
assert!(
|
||||
hi - lo <= 1_000,
|
||||
"target still swinging between {lo} and {hi} kbps after 30 seconds"
|
||||
);
|
||||
assert!(
|
||||
hi <= 2_400,
|
||||
"settled at {hi} kbps against a path carrying 2000"
|
||||
);
|
||||
}
|
||||
|
||||
/// The reported depth is what the next session will be judged on, so it
|
||||
/// has to be right: bytes over the rate they leave at, in milliseconds.
|
||||
#[test]
|
||||
fn the_queue_depth_is_reported_as_measured() {
|
||||
let mut c = controller();
|
||||
// 250 kB queued behind a 2 Mbps drain is exactly one second.
|
||||
c.tick(1, Some(report(60, 0, 2_000)), backlogged(250_000), false);
|
||||
assert_eq!(c.backlog_ms(), 1_000);
|
||||
|
||||
// A transport that cannot say must report no queue, not a wrong one.
|
||||
c.tick(1, Some(report(60, 0, 2_000)), PathView::default(), false);
|
||||
assert_eq!(c.backlog_ms(), 0);
|
||||
}
|
||||
|
||||
/// The backlog must not become a reason never to climb again.
|
||||
#[test]
|
||||
fn a_clear_queue_still_climbs() {
|
||||
let mut c = controller();
|
||||
c.set_ceiling(4_000);
|
||||
let clear = backlogged(0);
|
||||
for _ in 0..5 {
|
||||
c.tick(1, Some(report(60, 0, 3_900)), clear, false);
|
||||
}
|
||||
assert_eq!(c.reason(), Reason::Climbing);
|
||||
}
|
||||
|
||||
/// The reported failure, as measured: a 10 Mbps offer into a path carrying
|
||||
/// under three, where *no* frame completed for minutes.
|
||||
#[test]
|
||||
fn the_reported_failure_is_corrected_in_seconds() {
|
||||
let mut c = controller();
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
|
||||
let mut ticks = 0;
|
||||
while c.target_kbps() > 2_800 && ticks < 10 {
|
||||
// 46 frames a second starting to arrive, none of them completing.
|
||||
c.tick(1, Some(report(0, 46, 0)), PathView::default(), false);
|
||||
ticks += 1;
|
||||
}
|
||||
assert!(
|
||||
c.target_kbps() <= 2_800,
|
||||
"still at {} kbps after {ticks} seconds",
|
||||
c.target_kbps(),
|
||||
);
|
||||
assert!(
|
||||
ticks <= 5,
|
||||
"took {ticks} seconds to stop overrunning the path"
|
||||
);
|
||||
}
|
||||
|
||||
/// With a goodput measurement to anchor on it should take one step, not
|
||||
/// several -- that is the whole reason for backing off to what arrived
|
||||
/// rather than to a fraction of what was asked for.
|
||||
#[test]
|
||||
fn a_measured_goodput_is_corrected_in_one_step() {
|
||||
let mut c = controller();
|
||||
let sent = c.tick(1, Some(report(12, 48, 2_850)), PathView::default(), false);
|
||||
assert_eq!(sent, Some(c.target_kbps()));
|
||||
assert!(
|
||||
c.target_kbps() < 2_850,
|
||||
"backed off to {} kbps, which is above what actually arrived",
|
||||
c.target_kbps(),
|
||||
);
|
||||
}
|
||||
|
||||
/// The negative that matters most: a path that is fine must be left alone.
|
||||
#[test]
|
||||
fn a_healthy_path_is_not_wandered_away_from() {
|
||||
let mut c = controller();
|
||||
// The first decision is always stated. The encoder started at whatever
|
||||
// its environment gave it and this end cannot know that matches, so the
|
||||
// target is asserted once rather than assumed.
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
Some(CEILING)
|
||||
);
|
||||
for _ in 0..60 {
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
None,
|
||||
"a healthy path produced a bitrate change",
|
||||
);
|
||||
}
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_decision_is_always_stated() {
|
||||
// Otherwise a session whose path happens to match its ceiling never
|
||||
// tells the encoder anything, and the encoder keeps whatever its
|
||||
// environment gave it -- which is the failure this replaces.
|
||||
let mut c = controller();
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
Some(CEILING),
|
||||
"the opening target was never stated",
|
||||
);
|
||||
// And not repeated, now that the encoder has been told.
|
||||
assert_eq!(c.tick(1, Some(healthy()), PathView::default(), false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_small_change_is_not_worth_saying() {
|
||||
// Below the deadband nothing is sent, so the log and the encoder are not
|
||||
// touched sixty times a minute for changes nobody could see.
|
||||
let l = limits();
|
||||
assert!(!worth_sending(5_000, 5_200, l));
|
||||
assert!(worth_sending(5_000, 5_600, l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaching_either_end_of_the_band_is_always_worth_saying() {
|
||||
let l = limits();
|
||||
assert!(worth_sending(l.floor_kbps() + 1, l.floor_kbps(), l));
|
||||
assert!(worth_sending(CEILING - 1, CEILING, l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ceiling_is_never_exceeded() {
|
||||
let mut c = controller();
|
||||
for _ in 0..200 {
|
||||
c.tick(1, Some(healthy()), PathView::default(), false);
|
||||
assert!(c.target_kbps() <= CEILING, "{} kbps", c.target_kbps());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_floor_is_never_gone_below() {
|
||||
let mut c = controller();
|
||||
for _ in 0..200 {
|
||||
c.tick(1, Some(report(0, 60, 0)), PathView::default(), false);
|
||||
assert!(
|
||||
c.target_kbps() >= c.limits().floor_kbps(),
|
||||
"{} kbps is below the floor",
|
||||
c.target_kbps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_climbs_back_after_a_bad_patch() {
|
||||
let mut c = controller();
|
||||
for _ in 0..5 {
|
||||
c.tick(1, Some(report(0, 46, 0)), PathView::default(), false);
|
||||
}
|
||||
let bottom = c.target_kbps();
|
||||
for _ in 0..40 {
|
||||
c.tick(1, Some(healthy()), PathView::default(), false);
|
||||
}
|
||||
assert!(
|
||||
c.target_kbps() > bottom,
|
||||
"never recovered from {bottom} kbps"
|
||||
);
|
||||
assert_eq!(c.target_kbps(), CEILING, "did not climb all the way back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_the_hub_starved_is_not_read_as_a_bad_path() {
|
||||
// While a client resynchronises its deltas are withheld, so fewer frames
|
||||
// are sent, fewer arrive, goodput reads low and loss reads high. Backing
|
||||
// off on that would cut the bitrate in response to the hub's own
|
||||
// decision -- and make every stutter cost bandwidth as well.
|
||||
let mut c = controller();
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
Some(CEILING)
|
||||
);
|
||||
|
||||
let starved = report(0, 46, 0);
|
||||
for _ in 0..10 {
|
||||
assert_eq!(c.tick(1, Some(starved), PathView::default(), true), None);
|
||||
}
|
||||
assert_eq!(c.target_kbps(), CEILING, "the hub cut its own bitrate");
|
||||
assert_eq!(c.reason(), Reason::SelfInflicted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_genuinely_bad_second_still_acts_once_the_hub_stops_starving_it() {
|
||||
// The flag holds, it does not blind. The moment a second is the path's
|
||||
// own, the same evidence is acted on.
|
||||
let mut c = controller();
|
||||
c.tick(1, Some(healthy()), PathView::default(), false);
|
||||
c.tick(1, Some(report(0, 46, 0)), PathView::default(), true);
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
|
||||
c.tick(1, Some(report(12, 48, 2_850)), PathView::default(), false);
|
||||
assert!(c.target_kbps() < 2_850, "a real bad second was ignored too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_starved_second_does_not_count_towards_silence() {
|
||||
// It is not a missing report -- one arrived, it just cannot be read.
|
||||
// Counting it as silence would slide towards the path-based fallback and
|
||||
// then to decaying blind, which is a different wrong answer.
|
||||
let mut c = controller();
|
||||
c.tick(1, Some(healthy()), PathView::default(), false);
|
||||
for _ in 0..10 {
|
||||
c.tick(1, Some(report(0, 60, 0)), PathView::default(), true);
|
||||
}
|
||||
assert_eq!(c.reason(), Reason::SelfInflicted);
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hand_set_bitrate_stands_the_controller_down() {
|
||||
// How this class of bug gets diagnosed. A controller that overrode a
|
||||
// person's setting a second later would take the tool away.
|
||||
let mut c = controller();
|
||||
c.note_manual_target(1_000);
|
||||
for _ in 0..30 {
|
||||
assert_eq!(
|
||||
c.tick(1, Some(report(0, 60, 0)), PathView::default(), false),
|
||||
None
|
||||
);
|
||||
}
|
||||
assert_eq!(c.target_kbps(), 1_000);
|
||||
assert_eq!(c.reason(), Reason::Manual);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_quality_has_no_bitrate_to_decide() {
|
||||
let mut c = controller();
|
||||
c.set_constant_quality(true);
|
||||
assert_eq!(
|
||||
c.tick(1, Some(report(0, 60, 0)), PathView::default(), false),
|
||||
None
|
||||
);
|
||||
assert_eq!(c.reason(), Reason::ConstantQuality);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn silence_is_tolerated_briefly_and_then_acted_on() {
|
||||
let mut c = controller();
|
||||
// A missed report or two says nothing; the path was fine a second ago.
|
||||
for _ in 0..(SILENT_TICKS_BEFORE_FALLBACK - 1) {
|
||||
assert_eq!(c.tick(1, None, PathView::default(), false), None);
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
}
|
||||
// Past that, with nothing visible from this end either, it decays rather
|
||||
// than holding a high target on no evidence at all -- holding is how the
|
||||
// original failure sustained itself.
|
||||
c.tick(1, None, PathView::default(), false);
|
||||
assert_eq!(c.reason(), Reason::Blind);
|
||||
assert!(c.target_kbps() < CEILING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn this_ends_view_is_used_only_when_the_far_end_is_silent() {
|
||||
let mut c = controller();
|
||||
let path = PathView {
|
||||
cwnd_bytes: Some(120_000),
|
||||
rtt_ms: Some(200),
|
||||
backlog_bytes: None,
|
||||
};
|
||||
// A report present means the path view is ignored, however tempting.
|
||||
c.tick(1, Some(healthy()), path, false);
|
||||
assert_eq!(c.reason(), Reason::Climbing);
|
||||
|
||||
for _ in 0..SILENT_TICKS_BEFORE_FALLBACK {
|
||||
c.tick(1, None, path, false);
|
||||
}
|
||||
assert_eq!(c.reason(), Reason::Fallback);
|
||||
// 120 KB in flight per 200 ms is 4.8 Mbps.
|
||||
assert_eq!(c.target_kbps(), 4_800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_decided_while_nobody_is_connected() {
|
||||
// A box waiting for its first client used to read the silence as a dead
|
||||
// path and wind itself down to the floor, then jump back up the moment
|
||||
// somebody arrived. There is no path to have an opinion about yet.
|
||||
let mut c = controller();
|
||||
for _ in 0..30 {
|
||||
assert_eq!(c.tick(0, None, PathView::default(), false), None);
|
||||
}
|
||||
assert_eq!(c.target_kbps(), CEILING);
|
||||
assert_eq!(c.reason(), Reason::NoClients);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lowered_ceiling_can_be_raised_again() {
|
||||
// The trap in folding the box's ceiling together with the one in force:
|
||||
// after lowering, the only number left to compare against is the lowered
|
||||
// one, so the client can never get back up.
|
||||
let mut c = controller();
|
||||
c.set_ceiling(2_000);
|
||||
assert_eq!(c.limits().ceiling_kbps, 2_000);
|
||||
c.set_ceiling(5_000);
|
||||
assert_eq!(c.limits().ceiling_kbps, 5_000);
|
||||
assert_eq!(c.box_ceiling_kbps(), CEILING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ceiling_change_is_restated_to_the_encoder() {
|
||||
// The encoder is the thing that has to act on it, and a target that
|
||||
// happens to land on the same number is still a different instruction
|
||||
// when the band around it moved.
|
||||
let mut c = controller();
|
||||
assert!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false)
|
||||
.is_some()
|
||||
);
|
||||
c.set_ceiling(3_000);
|
||||
assert_eq!(
|
||||
c.tick(1, Some(healthy()), PathView::default(), false),
|
||||
Some(3_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_may_lower_its_ceiling_but_not_raise_it() {
|
||||
let mut c = controller();
|
||||
c.set_ceiling(4_000);
|
||||
assert_eq!(c.limits().ceiling_kbps, 4_000);
|
||||
assert!(c.target_kbps() <= 4_000, "the target outlived its ceiling");
|
||||
|
||||
c.set_ceiling(50_000);
|
||||
assert_eq!(
|
||||
c.limits().ceiling_kbps,
|
||||
CEILING,
|
||||
"a client raised the ceiling the tier bought",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ceiling_under_the_floor_does_not_invert_the_band() {
|
||||
// A misconfigured tier must not produce a floor above its own ceiling,
|
||||
// which would put every target above the limit it exists to enforce.
|
||||
let l = Limits::new(500);
|
||||
assert!(l.floor_kbps() <= l.ceiling_kbps);
|
||||
assert_eq!(l.clamp(10_000), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_silent_second_is_not_read_as_a_healthy_one() {
|
||||
// No frames at all accounted for: nothing sent, or nothing arrived.
|
||||
// Reading it as no loss would climb straight into a path that may be
|
||||
// carrying nothing.
|
||||
let mut c = controller();
|
||||
let empty = ReceiverReport::default();
|
||||
assert_eq!(empty.loss(), None);
|
||||
c.tick(1, Some(empty), PathView::default(), false);
|
||||
assert_eq!(c.target_kbps(), CEILING, "climbed on an empty report");
|
||||
}
|
||||
}
|
||||
+204
-31
@@ -143,6 +143,73 @@ impl DatagramSender {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long deltas may be withheld from a client waiting to resynchronise.
|
||||
///
|
||||
/// A bound rather than a belief. Withholding is correct only while the keyframe
|
||||
/// is actually coming, and if the encoder never produces one -- it refused, it
|
||||
/// died, the request never reached it -- then withholding forever turns a
|
||||
/// recoverable freeze into a permanent black screen. Past this the deltas go out
|
||||
/// again: useless to a desynchronised decoder, but "useless" beats "nothing at
|
||||
/// all, forever" when the assumption behind the suppression has been proven
|
||||
/// wrong.
|
||||
const MAX_RESYNC_WITHHOLD: std::time::Duration = std::time::Duration::from_secs(2);
|
||||
|
||||
/// Whether to send delta frames to a client that cannot decode them yet.
|
||||
///
|
||||
/// A client that has lost synchronisation asks for a keyframe, and until one
|
||||
/// arrives every delta frame sent to it is undecodable -- it predicts from
|
||||
/// pictures that client does not have. Those frames are not merely wasted: quinn
|
||||
/// writes DATAGRAM frames into a packet before STREAM frames, so a steady stream
|
||||
/// of deltas takes the space the keyframe needs and starves the one frame that
|
||||
/// would end the freeze. That is the loop behind "26 keyframe fallbacks, 19 IDR
|
||||
/// requests": the recovery frame could not get out past the frames that needed
|
||||
/// it to arrive first.
|
||||
///
|
||||
/// So while a client is waiting, its deltas are dropped rather than sent.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResyncGate {
|
||||
/// When this client started waiting, or `None` if it is not.
|
||||
waiting_since: Option<std::time::Instant>,
|
||||
/// Deltas dropped during the current wait.
|
||||
withheld: u64,
|
||||
/// Set once the withhold bound is passed, so it is said once per wait.
|
||||
gave_up: bool,
|
||||
}
|
||||
|
||||
impl ResyncGate {
|
||||
/// Whether this delta frame should go out.
|
||||
///
|
||||
/// `awaiting` is whether the client has asked for a keyframe and not yet
|
||||
/// been sent one.
|
||||
pub fn admit_delta(&mut self, awaiting: bool, now: std::time::Instant) -> bool {
|
||||
if !awaiting {
|
||||
self.end_wait();
|
||||
return true;
|
||||
}
|
||||
let since = *self.waiting_since.get_or_insert(now);
|
||||
if now.duration_since(since) >= MAX_RESYNC_WITHHOLD {
|
||||
self.gave_up = true;
|
||||
return true;
|
||||
}
|
||||
self.withheld += 1;
|
||||
false
|
||||
}
|
||||
|
||||
/// A keyframe has gone out, so the wait is over.
|
||||
pub fn note_keyframe(&mut self) -> Option<(u64, bool)> {
|
||||
let withheld = self.withheld;
|
||||
let gave_up = self.gave_up;
|
||||
self.end_wait();
|
||||
(withheld > 0).then_some((withheld, gave_up))
|
||||
}
|
||||
|
||||
fn end_wait(&mut self) {
|
||||
self.waiting_since = None;
|
||||
self.withheld = 0;
|
||||
self.gave_up = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames arriving on `rx` are numbered and sent — deltas as datagrams,
|
||||
/// keyframes on a reliable stream each when `keyframes_reliable` is set.
|
||||
///
|
||||
@@ -161,6 +228,12 @@ pub async fn run_datagram_writer(
|
||||
mut rx: tokio::sync::mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
relay_ms: Option<Arc<AtomicU32>>,
|
||||
keyframes_reliable: bool,
|
||||
// Set while this client has asked for a keyframe and not yet been sent
|
||||
// one. Video only; audio has no such notion.
|
||||
awaiting_keyframe: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
// Counts frames withheld during a resynchronisation, so the bitrate
|
||||
// controller can tell a second it starved from a second the path did.
|
||||
withheld: Option<Arc<std::sync::atomic::AtomicU64>>,
|
||||
) {
|
||||
let sender = DatagramSender::new(conn.clone(), kind);
|
||||
let keyframes = keyframes_reliable.then(|| KeyframeSender::new(conn, sender.clone()));
|
||||
@@ -170,33 +243,54 @@ pub async fn run_datagram_writer(
|
||||
// debug. Losing datagram support entirely is not, and is worth a warning —
|
||||
// but only the first time, since it will then be true for every frame.
|
||||
let mut warned_unsupported = false;
|
||||
|
||||
// Where this writer's second went.
|
||||
//
|
||||
// The pair that matters is the first two: if frames arrive here already
|
||||
// 43 ms apart then the hole was made upstream, in the encoder or on the IPC
|
||||
// hop, and nothing in this file can be the cause. If they arrive evenly and
|
||||
// leave unevenly, it is made here. A client measured exactly that hole in
|
||||
// video datagram arrivals while audio — same connection, same congestion
|
||||
// window, its own writer — stayed at 8 ms.
|
||||
let mut last_in = std::time::Instant::now();
|
||||
let mut worst_in_gap = std::time::Duration::ZERO;
|
||||
let mut worst_send = std::time::Duration::ZERO;
|
||||
let mut frames: u32 = 0;
|
||||
let mut last_pace = std::time::Instant::now();
|
||||
let mut resync = ResyncGate::default();
|
||||
|
||||
while let Some(payload) = rx.recv().await {
|
||||
let t0 = std::time::Instant::now();
|
||||
worst_in_gap = worst_in_gap.max(t0.duration_since(last_in));
|
||||
last_in = t0;
|
||||
frames += 1;
|
||||
|
||||
body.clear();
|
||||
nesprotocol::encode_frame_body(&mut body, MSG_DATA, seq, &payload);
|
||||
|
||||
// A keyframe goes on a stream of its own when one will take it. The
|
||||
// relay timing below is not recorded for it: the write is asynchronous
|
||||
// by design, so the time this loop spent on it says nothing.
|
||||
// A client that cannot decode has asked for a keyframe; until it gets
|
||||
// one, everything else sent to it is undecodable and takes the space the
|
||||
// keyframe needs. See `ResyncGate`.
|
||||
if let Some(ref awaiting) = awaiting_keyframe {
|
||||
if nesprotocol::reliable::video_is_keyframe(&payload) {
|
||||
awaiting.store(false, Ordering::Relaxed);
|
||||
if let Some((withheld, gave_up)) = resync.note_keyframe() {
|
||||
debug!(
|
||||
"{label}: resynchronised after withholding {withheld} frame(s){}",
|
||||
if gave_up {
|
||||
", having given up waiting"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
}
|
||||
} else if !resync
|
||||
.admit_delta(awaiting.load(Ordering::Relaxed), std::time::Instant::now())
|
||||
{
|
||||
// The sequence number deliberately does *not* advance. A
|
||||
// withheld frame was never sent, so leaving a hole would make
|
||||
// the receiver count it as lost -- and that count is what the
|
||||
// bitrate controller reads. The hub would then lower the
|
||||
// bitrate because of frames it chose not to send, which is a
|
||||
// controller reacting to its own decision rather than to the
|
||||
// path. Reusing the number is safe precisely because nothing
|
||||
// went out under it.
|
||||
//
|
||||
// Saying so is the other half: the receiver still reports fewer
|
||||
// frames this second, and without this count the controller has
|
||||
// no way to tell that second from one the path ruined.
|
||||
if let Some(ref withheld) = withheld {
|
||||
withheld.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref keyframes) = keyframes
|
||||
&& video_wants_reliable(&payload)
|
||||
{
|
||||
@@ -229,19 +323,6 @@ pub async fn run_datagram_writer(
|
||||
}
|
||||
Err(e) => debug!("{label}: dropping frame {seq}: {e}"),
|
||||
}
|
||||
worst_send = worst_send.max(t0.elapsed());
|
||||
|
||||
if last_pace.elapsed() >= std::time::Duration::from_secs(1) {
|
||||
last_pace = std::time::Instant::now();
|
||||
debug!(
|
||||
"{label}: {frames} frames, worst gap between frames in {:.1}ms, worst send {:.1}ms",
|
||||
worst_in_gap.as_secs_f64() * 1000.0,
|
||||
worst_send.as_secs_f64() * 1000.0,
|
||||
);
|
||||
worst_in_gap = std::time::Duration::ZERO;
|
||||
worst_send = std::time::Duration::ZERO;
|
||||
frames = 0;
|
||||
}
|
||||
|
||||
seq = seq.wrapping_add(1);
|
||||
}
|
||||
@@ -252,3 +333,95 @@ pub async fn run_datagram_writer(
|
||||
}
|
||||
debug!("{label} datagram writer exiting (channel closed)");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod resync_gate_tests {
|
||||
use super::{MAX_RESYNC_WITHHOLD, ResyncGate};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn a_client_that_can_decode_gets_everything() {
|
||||
// The negative that matters: nothing is withheld from a healthy client.
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
for i in 0..1000 {
|
||||
assert!(gate.admit_delta(false, now + Duration::from_millis(i)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_client_is_not_sent_frames_it_cannot_decode() {
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
for i in 0..60 {
|
||||
assert!(
|
||||
!gate.admit_delta(true, now + Duration::from_millis(i * 16)),
|
||||
"frame {i} went to a client with no reference to decode it against",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_keyframe_ends_the_wait_and_reports_the_cost() {
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
for i in 0..5 {
|
||||
gate.admit_delta(true, now + Duration::from_millis(i * 16));
|
||||
}
|
||||
assert_eq!(gate.note_keyframe(), Some((5, false)));
|
||||
// And the next delta goes out immediately.
|
||||
assert!(gate.admit_delta(false, now + Duration::from_millis(100)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyframe_with_nothing_withheld_says_nothing() {
|
||||
// So an ordinary periodic keyframe does not log a recovery that did not
|
||||
// happen.
|
||||
let mut gate = ResyncGate::default();
|
||||
assert_eq!(gate.note_keyframe(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn withholding_gives_up_rather_than_going_dark_forever() {
|
||||
// The bound. Withholding is only correct while the keyframe is actually
|
||||
// coming; if the encoder never produces one, suppressing forever turns a
|
||||
// recoverable freeze into a permanent black screen. Undecodable frames
|
||||
// beat no frames once the assumption is disproven.
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
assert!(!gate.admit_delta(true, now));
|
||||
assert!(!gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD - Duration::from_millis(1)));
|
||||
assert!(
|
||||
gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD),
|
||||
"still withholding after the keyframe plainly is not coming",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn giving_up_is_reported_when_the_keyframe_finally_lands() {
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
gate.admit_delta(true, now);
|
||||
gate.admit_delta(true, now + MAX_RESYNC_WITHHOLD);
|
||||
let (withheld, gave_up) = gate.note_keyframe().expect("something was withheld");
|
||||
assert_eq!(withheld, 1);
|
||||
assert!(gave_up, "the wait timed out and nothing said so");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_wait_starts_fresh() {
|
||||
// Otherwise the first wait's elapsed time carries over and the second is
|
||||
// abandoned immediately, or its count reports the wrong recovery.
|
||||
let mut gate = ResyncGate::default();
|
||||
let now = Instant::now();
|
||||
gate.admit_delta(true, now);
|
||||
gate.note_keyframe();
|
||||
|
||||
let later = now + Duration::from_secs(60);
|
||||
assert!(
|
||||
!gate.admit_delta(true, later),
|
||||
"the new wait was not honoured"
|
||||
);
|
||||
assert_eq!(gate.note_keyframe(), Some((1, false)));
|
||||
}
|
||||
}
|
||||
|
||||
+155
-12
@@ -1,3 +1,4 @@
|
||||
mod control;
|
||||
mod dgram;
|
||||
mod ipc_listener;
|
||||
mod keyframe;
|
||||
@@ -14,7 +15,7 @@ use iroh::endpoint::presets;
|
||||
|
||||
use crate::session::SessionManager;
|
||||
use crate::ticket::NestriTicket;
|
||||
use nesprotocol::ALPN;
|
||||
use nesprotocol::{ALPNS, Carrier};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "neshub")]
|
||||
@@ -73,6 +74,16 @@ struct Args {
|
||||
#[arg(long, env = "NESTRI_AUDIO_BITRATE", default_value_t = 64)]
|
||||
audio_bitrate_per_channel: u32,
|
||||
|
||||
/// Ceiling on the video bitrate, in kbps.
|
||||
///
|
||||
/// Set by `nesinit` from the boot descriptor's video limits, which come
|
||||
/// from the tier the box was sized for. Absent means nobody said -- which is
|
||||
/// not a licence to send whatever the encoder defaults to, since that is
|
||||
/// precisely how every session came to offer 10 Mbps regardless of what the
|
||||
/// path could carry. Unset is reported, and a conservative ceiling is used.
|
||||
#[arg(long, env = "NESTRI_MAX_BITRATE")]
|
||||
max_bitrate_kbps: Option<u32>,
|
||||
|
||||
/// Socket nescope sends screenshots on. neshub listens; nescope dials out.
|
||||
#[arg(
|
||||
long,
|
||||
@@ -82,6 +93,14 @@ struct Args {
|
||||
screenshot_ipc: PathBuf,
|
||||
}
|
||||
|
||||
/// What to assume when nobody said.
|
||||
///
|
||||
/// Deliberately modest. A ceiling that was never set should not behave like an
|
||||
/// unlimited one: the whole failure this exists to fix was a session offering
|
||||
/// 10 Mbps into a path carrying under three, because no number had ever been
|
||||
/// chosen and the encoder's own default stood in for one.
|
||||
const DEFAULT_MAX_BITRATE_KBPS: u32 = 4_000;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
@@ -93,7 +112,7 @@ async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
|
||||
let mut builder = iroh::Endpoint::builder(presets::N0)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.alpns(ALPNS.iter().map(|a| a.to_vec()).collect::<Vec<_>>())
|
||||
.transport_config(crate::dgram::media_transport_config());
|
||||
|
||||
match args.relay.as_str() {
|
||||
@@ -117,6 +136,15 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
match args.max_bitrate_kbps {
|
||||
Some(kbps) => tracing::info!("video ceiling: {kbps} kbps, from the boot descriptor"),
|
||||
None => tracing::warn!(
|
||||
"no video ceiling on the boot descriptor; using {DEFAULT_MAX_BITRATE_KBPS} kbps. \
|
||||
A box sized by a tier is told its ceiling -- if this is one, the descriptor did \
|
||||
not carry it."
|
||||
),
|
||||
}
|
||||
|
||||
let endpoint = builder.bind().await?;
|
||||
let endpoint_addr = endpoint.addr();
|
||||
let ep_id = endpoint_addr.id;
|
||||
@@ -134,6 +162,14 @@ async fn main() -> Result<()> {
|
||||
|
||||
let session_manager = Arc::new(SessionManager::new());
|
||||
|
||||
// One controller for the box, not one per client: there is one encoder, so
|
||||
// there is one bitrate, and the client having the worst time is the one it
|
||||
// has to answer.
|
||||
let box_ceiling_kbps = args.max_bitrate_kbps.unwrap_or(DEFAULT_MAX_BITRATE_KBPS);
|
||||
let controller = Arc::new(tokio::sync::Mutex::new(control::Controller::new(
|
||||
control::Limits::new(box_ceiling_kbps),
|
||||
)));
|
||||
|
||||
// IDR / encode settings command channel: input reader → nescapture
|
||||
let (cmd_tx, mut cmd_rx) = tokio::sync::mpsc::unbounded_channel::<Vec<u8>>();
|
||||
{
|
||||
@@ -181,23 +217,105 @@ async fn main() -> Result<()> {
|
||||
args.audio_channels,
|
||||
args.audio_bitrate_per_channel
|
||||
);
|
||||
let controller = controller.clone();
|
||||
let cmd_tx = cmd_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
// Ten times a second, and only when asked for. Pairs with
|
||||
// nescapture's rate probe: that measures how fast the encoder
|
||||
// follows a new bitrate, this shows how fast the queue downstream
|
||||
// of it responds, and the slower of the two is the fastest a
|
||||
// control loop can usefully run. At one sample a second neither is
|
||||
// visible -- a queue that fills and drains inside a second looks
|
||||
// like a queue that was never there.
|
||||
if std::env::var("NESHUB_BACKLOG_TRACE").is_ok_and(|v| v != "0" && !v.is_empty()) {
|
||||
let mgr = mgr.clone();
|
||||
let controller = controller.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut fast = tokio::time::interval(std::time::Duration::from_millis(100));
|
||||
tracing::info!("backlog trace on, 10 Hz");
|
||||
loop {
|
||||
fast.tick().await;
|
||||
let backlogs = mgr.backlog_bytes().await;
|
||||
if backlogs.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let target = controller.lock().await.target_kbps();
|
||||
for bytes in backlogs {
|
||||
// Against the target rather than a measured drain:
|
||||
// this is a trace to read afterwards, and a number
|
||||
// divided by a second measurement is two things
|
||||
// moving at once.
|
||||
let ms = bytes.saturating_mul(8) / u64::from(target.max(1));
|
||||
tracing::info!("backlog {ms} ms ({bytes} bytes) at {target} kbps");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let mut interval = tokio::time::interval(std::time::Duration::from_secs(1));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
// One decision a second, on the same tick as the stats, because
|
||||
// a report describes the second that just passed and there is
|
||||
// nothing to gain from deciding more often than they arrive.
|
||||
{
|
||||
let clients = mgr.client_count().await;
|
||||
let (report, path, self_inflicted) = mgr.worst_report().await;
|
||||
let mut controller = controller.lock().await;
|
||||
if let Some(kbps) = controller.tick(clients, report, path, self_inflicted) {
|
||||
let mut cmd = vec![nesprotocol::MSG_ENCODE_SETTINGS];
|
||||
nesprotocol::encode_bitrate_only(&mut cmd, kbps);
|
||||
if cmd_tx.send(cmd).is_err() {
|
||||
tracing::warn!("encoder command channel closed");
|
||||
} else {
|
||||
// Trace, not info: under a path that keeps moving
|
||||
// this fires every second, and a per-second line at
|
||||
// info buries everything worth reading.
|
||||
tracing::trace!(
|
||||
"video ceiling {}/{} kbps: {:?}",
|
||||
kbps,
|
||||
controller.limits().ceiling_kbps,
|
||||
controller.reason(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let clients = mgr.client_count().await as u8;
|
||||
let bitrate = mgr.video_bitrate_bps();
|
||||
let (key_bps, delta_bps, keyframes) = mgr.video_breakdown();
|
||||
let (pipeline_p50_ms, pipeline_p95_ms, pipeline_max_ms) = mgr.pipeline_delays();
|
||||
let audio_kbps = mgr.audio_bitrate_kbps();
|
||||
let relay_ms = mgr.relay_ms();
|
||||
let mut buf = Vec::with_capacity(15);
|
||||
let mut buf = Vec::with_capacity(34);
|
||||
nesprotocol::stats::encode_hub_stats(
|
||||
&mut buf,
|
||||
clients,
|
||||
bitrate,
|
||||
key_bps.saturating_add(delta_bps),
|
||||
relay_ms,
|
||||
audio_kbps,
|
||||
audio_channels,
|
||||
);
|
||||
{
|
||||
let controller = controller.lock().await;
|
||||
nesprotocol::stats::encode_video_breakdown(
|
||||
&mut buf,
|
||||
&nesprotocol::stats::VideoBreakdown {
|
||||
key_bps,
|
||||
delta_bps,
|
||||
keyframes,
|
||||
target_kbps: controller.target_kbps(),
|
||||
ceiling_kbps: controller.limits().ceiling_kbps,
|
||||
reason: controller.reason() as u8,
|
||||
manual: u8::from(controller.mode() == nesprotocol::ControlMode::Manual),
|
||||
box_ceiling_kbps: controller.box_ceiling_kbps(),
|
||||
pipeline_p50_ms,
|
||||
pipeline_p95_ms,
|
||||
pipeline_max_ms,
|
||||
backlog_ms: controller.backlog_ms().min(u32::from(u16::MAX)) as u16,
|
||||
},
|
||||
);
|
||||
}
|
||||
mgr.broadcast_stats(buf).await;
|
||||
}
|
||||
});
|
||||
@@ -262,18 +380,43 @@ async fn main() -> Result<()> {
|
||||
match incoming.await {
|
||||
Ok(conn) => {
|
||||
let remote_id = conn.remote_id();
|
||||
tracing::info!(remote = %remote_id.fmt_short(), "client connected");
|
||||
let session = session::ClientSession::new(
|
||||
// A client opens one connection per kind of traffic, so the
|
||||
// ALPN says which this is and the endpoint id says whose.
|
||||
let Some(carrier) = Carrier::from_alpn(conn.alpn()) else {
|
||||
tracing::warn!(
|
||||
remote = %remote_id.fmt_short(),
|
||||
"connection with an unknown ALPN; closing"
|
||||
);
|
||||
conn.close(0u32.into(), b"unknown alpn");
|
||||
continue;
|
||||
};
|
||||
tracing::info!(
|
||||
remote = %remote_id.fmt_short(),
|
||||
carrier = carrier.label(),
|
||||
"client connected"
|
||||
);
|
||||
mgr.attach(
|
||||
remote_id,
|
||||
carrier,
|
||||
conn.clone(),
|
||||
input_broadcast_tx.clone(),
|
||||
session_manager.relay_ms_atomic(),
|
||||
cmd_tx.clone(),
|
||||
);
|
||||
mgr.add_session(remote_id, session).await;
|
||||
controller.clone(),
|
||||
)
|
||||
.await;
|
||||
let mgr_clone = mgr.clone();
|
||||
let conn_clone = conn.clone();
|
||||
tokio::spawn(async move {
|
||||
conn_clone.closed().await;
|
||||
conn.closed().await;
|
||||
// Any one of them going means the session goes. A client
|
||||
// left holding audio and input but no video is not a
|
||||
// degraded session, it is a stuck one, and a clean
|
||||
// reconnect is both simpler to reason about and quicker
|
||||
// than whatever partial recovery would be built here.
|
||||
tracing::debug!(
|
||||
remote = %remote_id.fmt_short(),
|
||||
carrier = carrier.label(),
|
||||
"carrier closed, ending session"
|
||||
);
|
||||
mgr_clone.remove_session(&remote_id).await;
|
||||
});
|
||||
}
|
||||
|
||||
+773
-171
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
|
||||
pub mod filesystems;
|
||||
pub mod payload;
|
||||
pub mod platform;
|
||||
pub mod reap;
|
||||
pub mod services;
|
||||
pub mod session;
|
||||
|
||||
@@ -45,6 +45,7 @@ fn main() -> anyhow::Result<()> {
|
||||
// nothing else in this guest is an init system, so until this runs there
|
||||
// is no `/proc` to score this process in and nowhere to put a socket.
|
||||
nesinit::filesystems::establish();
|
||||
nesinit::platform::describe();
|
||||
|
||||
// Everything a distribution's init scripts used to do, and nothing else is
|
||||
// going to: a hostname, the box's address, the directories a session's
|
||||
@@ -80,6 +81,7 @@ fn main() -> anyhow::Result<()> {
|
||||
Ok(outcome) => tracing::info!(?outcome, "the session ended"),
|
||||
Err(error) => tracing::error!(%error, "the session failed"),
|
||||
}
|
||||
nesinit::platform::report_steal();
|
||||
|
||||
// Before anything below waits on a pid: the reaper runs on this runtime's
|
||||
// threads, and two things calling `wait` is what the registry exists to
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// What the kernel under this box actually chose, said once, at debug.
|
||||
//
|
||||
// Several of the settings that decide a box's latency are not decided by the
|
||||
// image. The clocksource is picked at boot and can be demoted by a watchdog,
|
||||
// the idle driver loads only when the host or the command line asks for it,
|
||||
// and the preemption model is a boot parameter. None of them is visible from
|
||||
// outside, and a box has no shell to ask with, so this reads them from sysfs
|
||||
// and procfs and logs them. Enable with `RUST_LOG=nesinit=debug` on the kernel
|
||||
// command line.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Clocksources a vDSO can read without entering the kernel.
|
||||
///
|
||||
/// Anything else makes every `clock_gettime` a syscall, and in a guest an
|
||||
/// emulated one: `hpet` is an MMIO read the host has to trap. A Windows game
|
||||
/// polls its performance counter constantly, so that is a cost paid thousands
|
||||
/// of times a frame, and nothing reports it.
|
||||
const VDSO_CLOCKSOURCES: &[&str] = &["tsc", "kvm-clock"];
|
||||
|
||||
/// Log the kernel's timing and idle choices. Needs `/proc` and `/sys`.
|
||||
pub fn describe() {
|
||||
let clocksource = read("/sys/devices/system/clocksource/clocksource0/current_clocksource");
|
||||
let cmdline = read("/proc/cmdline").unwrap_or_default();
|
||||
|
||||
tracing::debug!(
|
||||
clocksource = clocksource.as_deref().unwrap_or("unknown"),
|
||||
available = read("/sys/devices/system/clocksource/clocksource0/available_clocksource")
|
||||
.as_deref()
|
||||
.unwrap_or("unknown"),
|
||||
idle_driver = read("/sys/devices/system/cpu/cpuidle/current_driver")
|
||||
.as_deref()
|
||||
.unwrap_or("none"),
|
||||
idle_governor = read("/sys/devices/system/cpu/cpuidle/current_governor_ro")
|
||||
.as_deref()
|
||||
.unwrap_or("none"),
|
||||
// Present only while the haltpoll governor is built in, and only
|
||||
// meaningful while it is the governor in use.
|
||||
halt_poll_ns = read("/sys/module/haltpoll/parameters/guest_halt_poll_ns")
|
||||
.as_deref()
|
||||
.unwrap_or("n/a"),
|
||||
// The build's default when absent. The mode actually in effect is only
|
||||
// readable through debugfs, which this kernel does not have.
|
||||
preempt = kernel_parameter(&cmdline, "preempt").unwrap_or("default"),
|
||||
cpus = std::thread::available_parallelism().map_or(0, usize::from),
|
||||
kernel = read("/proc/sys/kernel/version")
|
||||
.as_deref()
|
||||
.unwrap_or("unknown"),
|
||||
"platform"
|
||||
);
|
||||
|
||||
if let Some(source) = clocksource.as_deref()
|
||||
&& !VDSO_CLOCKSOURCES.contains(&source)
|
||||
{
|
||||
tracing::warn!(
|
||||
clocksource = source,
|
||||
"every clock read in this box is a syscall; the kernel did not trust a faster clock"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Log how much of this box's CPU time the host took back. Call once, at the
|
||||
/// end of a session: the counters are cumulative since boot.
|
||||
pub fn report_steal() {
|
||||
let Some(stat) = read("/proc/stat") else {
|
||||
return;
|
||||
};
|
||||
let Some((steal, total)) = steal_of(&stat) else {
|
||||
return;
|
||||
};
|
||||
// Parts per thousand, so the log carries an integer and no float
|
||||
// formatting decides how small a number reads as zero.
|
||||
let permille = (steal * 1000).checked_div(total).unwrap_or(0);
|
||||
tracing::debug!(
|
||||
steal_ticks = steal,
|
||||
total_ticks = total,
|
||||
permille,
|
||||
"cpu time stolen by the host"
|
||||
);
|
||||
}
|
||||
|
||||
/// Steal and total ticks from the aggregate `cpu` line of `/proc/stat`.
|
||||
///
|
||||
/// Columns are user, nice, system, idle, iowait, irq, softirq, steal, and then
|
||||
/// guest time, which the kernel already counts inside user and nice. Summing
|
||||
/// past steal would count it twice.
|
||||
fn steal_of(stat: &str) -> Option<(u64, u64)> {
|
||||
let line = stat.lines().find(|l| l.starts_with("cpu "))?;
|
||||
let fields: Vec<u64> = line
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.take(8)
|
||||
.map(str::parse)
|
||||
.collect::<Result<_, _>>()
|
||||
.ok()?;
|
||||
let steal = *fields.get(7)?;
|
||||
Some((steal, fields.iter().sum()))
|
||||
}
|
||||
|
||||
/// The value of one bare `name=value` kernel parameter.
|
||||
fn kernel_parameter<'a>(cmdline: &'a str, name: &str) -> Option<&'a str> {
|
||||
cmdline
|
||||
.split_whitespace()
|
||||
.find_map(|word| word.strip_prefix(name)?.strip_prefix('='))
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn read(path: impl AsRef<Path>) -> Option<String> {
|
||||
std::fs::read_to_string(path)
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_parameter_is_found_by_its_whole_name() {
|
||||
let cmdline = "cpuidle_haltpoll.force=1 console=hvc0 preempt=full ro";
|
||||
assert_eq!(kernel_parameter(cmdline, "preempt"), Some("full"));
|
||||
assert_eq!(
|
||||
kernel_parameter(cmdline, "cpuidle_haltpoll.force"),
|
||||
Some("1")
|
||||
);
|
||||
}
|
||||
|
||||
/// `preempt` must not match `preempt_foo=`, which is a different parameter.
|
||||
#[test]
|
||||
fn a_longer_name_sharing_the_prefix_is_not_a_match() {
|
||||
assert_eq!(kernel_parameter("preempt_foo=x", "preempt"), None);
|
||||
assert_eq!(kernel_parameter("console=hvc0", "preempt"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn steal_is_the_eighth_column_and_guest_time_is_not_counted_twice() {
|
||||
// user nice system idle iowait irq softirq steal guest guest_nice
|
||||
let stat = "cpu 100 0 50 800 10 5 5 30 999 999\ncpu0 1 2 3 4 5 6 7 8 9 10\n";
|
||||
assert_eq!(steal_of(stat), Some((30, 1000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stat_without_a_cpu_line_reports_nothing() {
|
||||
assert_eq!(steal_of("intr 1 2 3\n"), None);
|
||||
assert_eq!(steal_of("cpu 1 2 3\n"), None);
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ use tokio::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use crate::reap::{Waiters, Watched};
|
||||
use crate::workload::Failure;
|
||||
use nesprotocol::lifecycle::VideoLimits;
|
||||
|
||||
/// A service that died, and how.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -60,7 +61,13 @@ pub trait Services {
|
||||
/// Called once, after the shares are mounted and before anything may be
|
||||
/// launched. An empty stack is legitimate: a box with no services still
|
||||
/// boots, and a caller can still launch something that needs none.
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure>;
|
||||
///
|
||||
/// `video` comes from the descriptor and reaches the services that read it.
|
||||
/// It has to arrive here rather than later because a service configured
|
||||
/// after it is already running has a window in which it is not configured,
|
||||
/// and for a bitrate ceiling that window is a session streaming at whatever
|
||||
/// default it started with.
|
||||
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure>;
|
||||
|
||||
/// Deaths, as they happen.
|
||||
///
|
||||
@@ -160,6 +167,17 @@ pub const SERVICE_UID: u32 = 1000;
|
||||
/// here: the directory belongs to the service user and is not writable by the
|
||||
/// workload, which is the property [`crate::ticket::Untrusted`] depends on.
|
||||
pub const AUDIO_DIR: &str = "/run/pipewire";
|
||||
|
||||
/// Where the PulseAudio protocol is served, in [`AUDIO_DIR`] for the reason
|
||||
/// audio's own socket is.
|
||||
///
|
||||
/// pipewire-pulse is told this path by its configuration file in the image,
|
||||
/// which is not something this program can pass it, so the two are compared by
|
||||
/// a test rather than trusted to agree.
|
||||
pub const PULSE_SOCKET: &str = "/run/pipewire/pulse-native";
|
||||
|
||||
/// [`PULSE_SOCKET`] as a PulseAudio client is told it.
|
||||
pub const PULSE_SERVER: &str = "unix:/run/pipewire/pulse-native";
|
||||
pub const SERVICE_GID: u32 = 1000;
|
||||
|
||||
/// Where a service's runtime sockets live.
|
||||
@@ -273,6 +291,26 @@ pub const STACK: &[Service] = &[
|
||||
umask: None,
|
||||
ready: None,
|
||||
},
|
||||
Service {
|
||||
name: "pipewire-pulse",
|
||||
argv: &["/usr/bin/pipewire-pulse"],
|
||||
env: &[
|
||||
("XDG_RUNTIME_DIR", RUNTIME_DIR),
|
||||
("PIPEWIRE_RUNTIME_DIR", AUDIO_DIR),
|
||||
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/1000/bus"),
|
||||
],
|
||||
user: Some((SERVICE_UID, SERVICE_GID)),
|
||||
// Optional for the reason the sender is: everything that speaks
|
||||
// PipeWire itself is unaffected, and a session with sound missing is
|
||||
// degraded rather than unusable.
|
||||
cost: "anything that only speaks PulseAudio plays silently, and Wine is one",
|
||||
required: false,
|
||||
// The workload is its client, and is not this user.
|
||||
umask: Some(0),
|
||||
// Nothing in this table connects to it, but the workload does, and the
|
||||
// workload is started after the table.
|
||||
ready: Some(PULSE_SOCKET),
|
||||
},
|
||||
Service {
|
||||
name: "neswire",
|
||||
argv: &["/usr/bin/neswire"],
|
||||
@@ -308,6 +346,13 @@ pub struct Stack {
|
||||
running: Vec<(&'static str, Watched)>,
|
||||
deaths: Receiver<Died>,
|
||||
reported: Sender<Died>,
|
||||
/// What the host said this box may spend on video, from the descriptor.
|
||||
///
|
||||
/// Held here because `spawn` is where it reaches a service, and `spawn`
|
||||
/// takes a `&'static Service` whose `env` is a fixed table -- a value that
|
||||
/// arrives at runtime has no route through it otherwise. The same problem
|
||||
/// `RUST_LOG` has, solved the same way.
|
||||
video: VideoLimits,
|
||||
}
|
||||
|
||||
impl Stack {
|
||||
@@ -328,6 +373,7 @@ impl Stack {
|
||||
running: Vec::new(),
|
||||
deaths,
|
||||
reported,
|
||||
video: VideoLimits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,6 +436,15 @@ impl Stack {
|
||||
if let Ok(filter) = std::env::var("RUST_LOG") {
|
||||
command.env("RUST_LOG", filter);
|
||||
}
|
||||
// The descriptor's video limits, for the services that read them. Same
|
||||
// shape of problem as `RUST_LOG` above -- `env_clear` drops everything
|
||||
// and the service table is a fixed list of literals, so a value that
|
||||
// only exists at runtime has no other route in. `neshub` reads this
|
||||
// through the clap `env =` attribute it already uses for every other
|
||||
// setting.
|
||||
if let Some(kbps) = self.video.bitrate_kbps {
|
||||
command.env("NESTRI_MAX_BITRATE", kbps.to_string());
|
||||
}
|
||||
// The service's own entry last, so a service that states one of these
|
||||
// for itself wins over the defaults above.
|
||||
command.envs(service.env.iter().copied());
|
||||
@@ -451,7 +506,8 @@ impl Stack {
|
||||
}
|
||||
|
||||
impl Services for Stack {
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure> {
|
||||
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure> {
|
||||
self.video = video;
|
||||
let mut up = Vec::new();
|
||||
// Lifted out so the loop does not hold a borrow of `self` across the
|
||||
// start it is asking for.
|
||||
@@ -604,6 +660,9 @@ pub mod double {
|
||||
/// only thing under test.
|
||||
pub struct Double {
|
||||
pub brought_up: usize,
|
||||
/// What the last `bring_up` was told, so a test can assert the limits
|
||||
/// reached the stack rather than assuming they did.
|
||||
pub video: VideoLimits,
|
||||
pub failure: Option<Failure>,
|
||||
pub names: Vec<String>,
|
||||
deaths: Receiver<Died>,
|
||||
@@ -625,6 +684,7 @@ pub mod double {
|
||||
names: vec!["dbus-system".into(), "neshub".into()],
|
||||
deaths,
|
||||
report,
|
||||
video: VideoLimits::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,8 +697,9 @@ pub mod double {
|
||||
}
|
||||
|
||||
impl Services for Double {
|
||||
fn bring_up(&mut self) -> Result<Vec<String>, Failure> {
|
||||
fn bring_up(&mut self, video: VideoLimits) -> Result<Vec<String>, Failure> {
|
||||
self.brought_up += 1;
|
||||
self.video = video;
|
||||
match &self.failure {
|
||||
Some(failure) => Err(failure.clone()),
|
||||
None => Ok(self.names.clone()),
|
||||
@@ -853,14 +914,42 @@ mod tests {
|
||||
/// owner -- and the workload is not the owner.
|
||||
#[test]
|
||||
fn the_audio_socket_is_reachable_by_a_user_who_does_not_own_it() {
|
||||
let pipewire = STACK
|
||||
for name in ["pipewire", "pipewire-pulse"] {
|
||||
let service = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == name)
|
||||
.unwrap_or_else(|| panic!("{name} is in the table"));
|
||||
assert_eq!(
|
||||
service.umask,
|
||||
Some(0),
|
||||
"with any other umask the game finds {name}'s socket and cannot open it"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The PulseAudio socket's path is written three times: here, in the
|
||||
/// client address the workload is given, and in pipewire-pulse's own
|
||||
/// configuration in the image. If any of them moves on its own, the game
|
||||
/// finds no server and plays silently, and nothing fails.
|
||||
#[test]
|
||||
fn pulse_is_served_where_the_workload_is_told_to_look() {
|
||||
assert!(
|
||||
PULSE_SOCKET.starts_with(AUDIO_DIR),
|
||||
"the workload can only reach sockets in {AUDIO_DIR}"
|
||||
);
|
||||
assert_eq!(PULSE_SERVER, format!("unix:{PULSE_SOCKET}"));
|
||||
|
||||
let pulse = STACK
|
||||
.iter()
|
||||
.find(|s| s.name == "pipewire")
|
||||
.expect("audio is in the table");
|
||||
assert_eq!(
|
||||
pipewire.umask,
|
||||
Some(0),
|
||||
"with any other umask the game finds the socket and cannot open it"
|
||||
.find(|s| s.name == "pipewire-pulse")
|
||||
.expect("pulse is in the table");
|
||||
assert_eq!(pulse.ready, Some(PULSE_SOCKET));
|
||||
|
||||
let config =
|
||||
include_str!("../../../build/etc/pipewire/pipewire-pulse.conf.d/50-nestri.conf");
|
||||
assert!(
|
||||
config.contains(&format!("\"{PULSE_SERVER}\"")),
|
||||
"pipewire-pulse is configured to listen somewhere other than {PULSE_SOCKET}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -266,19 +266,19 @@ where
|
||||
}
|
||||
booted = true;
|
||||
|
||||
// The drives, then the shares, then the services.
|
||||
// The overlays, then the shares, then the services.
|
||||
//
|
||||
// **Drives first, and one `Mounted` between them.** A drive is
|
||||
// a filesystem this end mounts itself, so a share whose target
|
||||
// lives under one has to find it already there. The host is
|
||||
// **Overlays first, and one `Mounted` between them.** An
|
||||
// overlay is a filesystem this end mounts itself, so a share
|
||||
// whose target lives under one has to find it already there. The host is
|
||||
// told once, after both, because `Mounted` answers "is the
|
||||
// content where the descriptor said" and there is one answer to
|
||||
// that -- sending it twice made the host read the second as a
|
||||
// reply to something it had not asked.
|
||||
if let Err(failure) = workload.mount_drives(&descriptor.drives) {
|
||||
if let Err(failure) = workload.mount_overlays(&descriptor.overlays) {
|
||||
// Said before it is returned. `Refused` ends the session
|
||||
// either way; without the message the host sees a box that
|
||||
// stopped and has to guess between a drive, a share and a
|
||||
// stopped and has to guess between an overlay, a share and a
|
||||
// service -- which is the whole reason these are reported
|
||||
// separately.
|
||||
send(
|
||||
@@ -311,7 +311,7 @@ where
|
||||
// A box whose own services will not come up cannot be launched
|
||||
// into, so this is refused rather than reported and carried on
|
||||
// from — unlike a launch, which is the caller's to correct.
|
||||
match services.bring_up() {
|
||||
match services.bring_up(descriptor.video) {
|
||||
Ok(up) => {
|
||||
tracing::info!(services = up.len(), "the box is ready to be launched into");
|
||||
send(&mut writer, &GuestToHost::Initialized { services: up }).await?
|
||||
@@ -516,7 +516,8 @@ mod tests {
|
||||
at: "/mnt/user".into(),
|
||||
ro: false,
|
||||
}],
|
||||
drives: Vec::new(),
|
||||
overlays: Vec::new(),
|
||||
video: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -715,6 +716,53 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_descriptors_video_limits_reach_the_services() {
|
||||
// The ceiling is useless if it stops at the descriptor. `neshub` is the
|
||||
// only thing that can enforce it and it is a service, so the number has
|
||||
// to survive the whole way from the boot document to the spawn.
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
let mut caller = Caller::new(host);
|
||||
let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0))));
|
||||
|
||||
let mut given = descriptor();
|
||||
given.video.bitrate_kbps = Some(8_000);
|
||||
|
||||
caller.expect_ready().await;
|
||||
caller
|
||||
.say(&HostToGuest::Boot {
|
||||
descriptor: Box::new(given),
|
||||
})
|
||||
.await;
|
||||
caller.expect_booted().await;
|
||||
caller.say(&HostToGuest::Shutdown).await;
|
||||
|
||||
let (_, _, services) = session.await.unwrap();
|
||||
assert_eq!(services.video.bitrate_kbps, Some(8_000));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_box_told_nothing_about_video_says_so_rather_than_inventing_a_limit() {
|
||||
// "Unsaid" must not arrive as a number. A stack that cannot tell the two
|
||||
// apart cannot log that it was never told, and a ceiling nobody set is
|
||||
// exactly how every session came to offer 10 Mbps.
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
let mut caller = Caller::new(host);
|
||||
let session = spawn(guest, Given::new(Double::exits_when_stopped(Exit::code(0))));
|
||||
|
||||
caller.expect_ready().await;
|
||||
caller
|
||||
.say(&HostToGuest::Boot {
|
||||
descriptor: Box::new(descriptor()),
|
||||
})
|
||||
.await;
|
||||
caller.expect_booted().await;
|
||||
caller.say(&HostToGuest::Shutdown).await;
|
||||
|
||||
let (_, _, services) = session.await.unwrap();
|
||||
assert_eq!(services.video.bitrate_kbps, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_launch_runs_what_it_names_and_is_reported_by_its_id() {
|
||||
let (guest, host) = tokio::io::duplex(4096);
|
||||
|
||||
+271
-81
@@ -11,7 +11,7 @@ use std::future::Future;
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
|
||||
use nesprotocol::lifecycle::{Drive, Exec, Exit, Mount};
|
||||
use nesprotocol::lifecycle::{Exec, Exit, Mount, Overlay};
|
||||
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
@@ -41,8 +41,9 @@ pub trait Workload {
|
||||
/// Make the shares the descriptor names, where it says to put them.
|
||||
fn mount(&mut self, mounts: &[Mount]) -> Result<(), Failure>;
|
||||
|
||||
/// Mount drives
|
||||
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure>;
|
||||
/// Stack each overlay the descriptor names: its build image, its writable
|
||||
/// layer, and the two together where it says.
|
||||
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure>;
|
||||
|
||||
/// Start the command the descriptor names.
|
||||
///
|
||||
@@ -132,9 +133,9 @@ impl Workload for Process {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> {
|
||||
for drive in drives {
|
||||
mount_drive(drive)?;
|
||||
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> {
|
||||
for overlay in overlays {
|
||||
mount_overlay(overlay)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -302,6 +303,9 @@ const GRAPHICS: &[(&str, &str)] = &[
|
||||
// reach and both are told where. Without this a game renders and plays
|
||||
// silently, having looked under its own uid and found nothing.
|
||||
("PIPEWIRE_RUNTIME_DIR", crate::services::AUDIO_DIR),
|
||||
// The same, for a client that speaks PulseAudio instead. It does not read
|
||||
// the variable above, and its default is under its own runtime directory.
|
||||
("PULSE_SERVER", crate::services::PULSE_SERVER),
|
||||
];
|
||||
|
||||
/// Mount one share where the descriptor says to put it.
|
||||
@@ -340,35 +344,104 @@ fn mount_share(share: &Mount) -> Result<(), Failure> {
|
||||
/// filesystem this mounts. A descriptor cannot name another.
|
||||
const FSTYPE: &std::ffi::CStr = c"virtiofs";
|
||||
|
||||
/// Mounts block device instead of virtiofs share
|
||||
fn mount_drive(drive: &Drive) -> Result<(), Failure> {
|
||||
// Checked before anything is created: a descriptor this component cannot
|
||||
// act on should leave no directory behind to confuse whoever reads the
|
||||
// failure.
|
||||
let (source, target, flags) = options_drive(drive)?;
|
||||
/// Stack one overlay: the build image, the box's writable layer, and the two
|
||||
/// together at `at`.
|
||||
///
|
||||
/// Each step's failure names the step, because "the install did not mount"
|
||||
/// has three different causes and each one is fixed somewhere else: a build
|
||||
/// image the kernel cannot read, an upper layer that was never formatted, and
|
||||
/// an overlay the kernel refused.
|
||||
fn mount_overlay(overlay: &Overlay) -> Result<(), Failure> {
|
||||
// Everything that can be refused without touching the filesystem is
|
||||
// refused first, so a descriptor this cannot act on leaves nothing behind.
|
||||
let plan = OverlayPlan::new(overlay)?;
|
||||
|
||||
// The mount point may not exist yet: a share can land anywhere the
|
||||
// descriptor names, including a directory no image created.
|
||||
std::fs::create_dir_all(&drive.at).map_err(|error| failed_drive(drive, error))?;
|
||||
mount_one(
|
||||
&plan.lower,
|
||||
&plan.lower_at,
|
||||
c"erofs",
|
||||
plan.lower_flags,
|
||||
None,
|
||||
)
|
||||
.map_err(|error| plan.failed("the build image", &plan.lower_at, error))?;
|
||||
mount_one(&plan.upper, &plan.rw_at, c"ext4", plan.upper_flags, None)
|
||||
.map_err(|error| plan.failed("the writable layer", &plan.rw_at, error))?;
|
||||
|
||||
// SAFETY: mount takes two paths, a filesystem name and a flag word, all
|
||||
// of which outlive the call, and no options string.
|
||||
for dir in [&plan.upper_dir, &plan.work_dir] {
|
||||
std::fs::create_dir_all(as_path(dir))
|
||||
.map_err(|error| plan.failed("the writable layer", dir, error))?;
|
||||
}
|
||||
// **The upper directory takes the build's root ownership.** overlayfs
|
||||
// shows a merged directory with the attributes of its upper half when it
|
||||
// has one, and this one always does -- so an upper directory this init
|
||||
// created, `root:root 0755`, would make the install's top directory
|
||||
// unwritable to the workload, whatever the build image says. Copied from
|
||||
// the lower root rather than named here: which uid the workload runs as is
|
||||
// the host's decision, and the host already made it when it packed the
|
||||
// image.
|
||||
let (uid, gid, mode) = ownership(&plan.lower_at)
|
||||
.map_err(|error| plan.failed("the build image", &plan.lower_at, error))?;
|
||||
set_ownership(&plan.upper_dir, uid, gid, mode)
|
||||
.map_err(|error| plan.failed("the writable layer", &plan.upper_dir, error))?;
|
||||
|
||||
mount_one(
|
||||
c"overlay",
|
||||
&plan.at,
|
||||
c"overlay",
|
||||
plan.overlay_flags,
|
||||
Some(&plan.overlay_data),
|
||||
)
|
||||
.map_err(|error| plan.failed("the overlay", &plan.at, error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mount one filesystem, creating its mount point first.
|
||||
fn mount_one(
|
||||
source: &std::ffi::CStr,
|
||||
target: &std::ffi::CStr,
|
||||
fstype: &std::ffi::CStr,
|
||||
flags: libc::c_ulong,
|
||||
data: Option<&std::ffi::CStr>,
|
||||
) -> io::Result<()> {
|
||||
// The mount point may not exist yet: the descriptor can name anywhere,
|
||||
// including a directory no image created.
|
||||
std::fs::create_dir_all(as_path(target))?;
|
||||
// SAFETY: every pointer is to a nul-terminated string that outlives the
|
||||
// call, and a null data pointer is what mount(2) takes for "no options".
|
||||
let mounted = unsafe {
|
||||
libc::mount(
|
||||
source.as_ptr(),
|
||||
target.as_ptr(),
|
||||
FSTYPE_DRIVE.as_ptr(),
|
||||
fstype.as_ptr(),
|
||||
flags,
|
||||
std::ptr::null(),
|
||||
data.map_or(std::ptr::null(), |d| d.as_ptr().cast()),
|
||||
)
|
||||
};
|
||||
if mounted != 0 {
|
||||
return Err(failed_drive(drive, io::Error::last_os_error()));
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const FSTYPE_DRIVE: &std::ffi::CStr = c"ext4";
|
||||
/// The same bytes, as a path: lossless, where a round trip through `str` is
|
||||
/// not.
|
||||
fn as_path(path: &std::ffi::CStr) -> &std::path::Path {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
std::path::Path::new(std::ffi::OsStr::from_bytes(path.to_bytes()))
|
||||
}
|
||||
|
||||
fn ownership(path: &std::ffi::CStr) -> io::Result<(u32, u32, u32)> {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let meta = std::fs::metadata(as_path(path))?;
|
||||
Ok((meta.uid(), meta.gid(), meta.mode() & 0o7777))
|
||||
}
|
||||
|
||||
fn set_ownership(path: &std::ffi::CStr, uid: u32, gid: u32, mode: u32) -> io::Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = as_path(path);
|
||||
std::os::unix::fs::chown(path, Some(uid), Some(gid))?;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
|
||||
}
|
||||
|
||||
/// What the mount call is given, split out because this is the part worth
|
||||
/// asserting: mounting itself needs privileges a test does not have.
|
||||
@@ -400,48 +473,114 @@ fn options(share: &Mount) -> Result<(CString, CString, libc::c_ulong), Failure>
|
||||
Ok((source, target, flags))
|
||||
}
|
||||
|
||||
/// What the drive mount call is given.
|
||||
/// Everything an overlay's three mounts are given, worked out before any of
|
||||
/// them is attempted.
|
||||
///
|
||||
/// # No filesystem-specific options, and that is a decision
|
||||
/// Split out because this is the part worth asserting: mounting needs
|
||||
/// privileges a test does not have.
|
||||
///
|
||||
/// `commit=` and `barrier=` were here once and the mount failed outright:
|
||||
/// *"can't mount with commit=, fs mounted w/o journal"*, `EINVAL`, and a box
|
||||
/// that refused its own descriptor before the session started. Both options
|
||||
/// only mean anything to a journal, and a build volume is made without one --
|
||||
/// what it holds is one game, re-downloadable, mounted by a clone that is
|
||||
/// destroyed with the box. Anything added here has to be an option that is
|
||||
/// still true of a journal-less ext4.
|
||||
/// # Where the layers go
|
||||
///
|
||||
/// `noatime` stays: a game reading its own install has no use for access
|
||||
/// times, and writing them turns every read of a clone into a write. It is not
|
||||
/// paired with `nodiratime`, which it already implies.
|
||||
/// Beside the overlay, in a hidden directory named after it:
|
||||
/// `/nestri/install` stacks `/nestri/.install/lower` under
|
||||
/// `/nestri/.install/rw/upper`. Beside rather than under, because anything
|
||||
/// mounted under `at` is covered the moment the overlay is mounted over it.
|
||||
///
|
||||
/// # nosuid and nodev, for the same reason every share has them
|
||||
/// # Flags
|
||||
///
|
||||
/// What this mounts is the least trusted thing in the box: files a CDN handed
|
||||
/// us, checked for the bytes the manifest named and for nothing about what
|
||||
/// those bytes are. A setuid binary or a device node inside a depot is not
|
||||
/// something a workload should be able to use, and no descriptor has a way to
|
||||
/// ask for one.
|
||||
/// `nosuid` and `nodev` on every layer and on the result, for the reason every
|
||||
/// share has them: what is stacked here is files a CDN handed us, checked for
|
||||
/// the bytes the manifest named and for nothing about what those bytes are.
|
||||
/// **Not `noexec`** anywhere: the game's executable is in the build.
|
||||
///
|
||||
/// **Not `noexec`.** The game's own executable is on this volume and the whole
|
||||
/// point is to run it.
|
||||
fn options_drive(drive: &Drive) -> Result<(CString, CString, libc::c_ulong), Failure> {
|
||||
let flags = libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOATIME;
|
||||
/// `noatime` on the writable layer and the overlay, so a game reading its own
|
||||
/// install does not turn every read into a write. The build image is mounted
|
||||
/// read-only and has no access times to write.
|
||||
///
|
||||
/// No filesystem-specific options on the upper layer: `commit=` and
|
||||
/// `barrier=` were once passed to a journal-less ext4 and the mount failed
|
||||
/// outright with `EINVAL`.
|
||||
#[derive(Debug)]
|
||||
struct OverlayPlan {
|
||||
lower: CString,
|
||||
upper: CString,
|
||||
at: CString,
|
||||
lower_at: CString,
|
||||
rw_at: CString,
|
||||
upper_dir: CString,
|
||||
work_dir: CString,
|
||||
lower_flags: libc::c_ulong,
|
||||
upper_flags: libc::c_ulong,
|
||||
overlay_flags: libc::c_ulong,
|
||||
overlay_data: CString,
|
||||
}
|
||||
|
||||
let source = CString::new(drive.dev.as_str()).map_err(|_| {
|
||||
impl OverlayPlan {
|
||||
fn new(overlay: &Overlay) -> Result<Self, Failure> {
|
||||
let at = std::path::Path::new(&overlay.at);
|
||||
let (Some(parent), Some(name)) = (at.parent(), at.file_name()) else {
|
||||
return Err(Failure::new(format!(
|
||||
"the overlay mount point has no parent to put its layers beside: {:?}",
|
||||
overlay.at
|
||||
)));
|
||||
};
|
||||
let layers = parent.join(format!(".{}", name.to_string_lossy()));
|
||||
let lower_at = layers.join("lower");
|
||||
let rw_at = layers.join("rw");
|
||||
let upper_dir = rw_at.join("upper");
|
||||
let work_dir = rw_at.join("work");
|
||||
|
||||
// overlayfs splits its options on commas and its layer lists on
|
||||
// colons, and has no escape for either that this should rely on. A
|
||||
// path carrying one would mount a different directory than the one
|
||||
// named, so it is refused.
|
||||
for path in [&lower_at, &upper_dir, &work_dir] {
|
||||
let text = path.to_string_lossy();
|
||||
if text.contains([',', ':']) {
|
||||
return Err(Failure::new(format!(
|
||||
"the overlay mount point cannot carry a comma or a colon: {:?}",
|
||||
overlay.at
|
||||
)));
|
||||
}
|
||||
}
|
||||
let data = format!(
|
||||
"lowerdir={},upperdir={},workdir={}",
|
||||
lower_at.display(),
|
||||
upper_dir.display(),
|
||||
work_dir.display()
|
||||
);
|
||||
|
||||
let common = libc::MS_NOSUID | libc::MS_NODEV;
|
||||
Ok(Self {
|
||||
lower: c_string(&overlay.lower, "the build image device")?,
|
||||
upper: c_string(&overlay.upper, "the writable layer device")?,
|
||||
at: c_string(&overlay.at, "the overlay mount point")?,
|
||||
lower_at: c_string(&lower_at.to_string_lossy(), "the overlay mount point")?,
|
||||
rw_at: c_string(&rw_at.to_string_lossy(), "the overlay mount point")?,
|
||||
upper_dir: c_string(&upper_dir.to_string_lossy(), "the overlay mount point")?,
|
||||
work_dir: c_string(&work_dir.to_string_lossy(), "the overlay mount point")?,
|
||||
lower_flags: common | libc::MS_RDONLY,
|
||||
upper_flags: common | libc::MS_NOATIME,
|
||||
overlay_flags: common | libc::MS_NOATIME,
|
||||
overlay_data: c_string(&data, "the overlay mount point")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Which step failed, on which path, in the operating system's words.
|
||||
fn failed(&self, step: &str, path: &std::ffi::CStr, error: io::Error) -> Failure {
|
||||
Failure::new(format!(
|
||||
"the drive device contains a nul byte: {:?}",
|
||||
drive.dev
|
||||
"{}: {step}: {}: {error}",
|
||||
self.at.to_string_lossy(),
|
||||
path.to_string_lossy()
|
||||
))
|
||||
})?;
|
||||
let target = CString::new(drive.at.as_str()).map_err(|_| {
|
||||
Failure::new(format!(
|
||||
"the drive mount point contains a nul byte: {:?}",
|
||||
drive.at
|
||||
))
|
||||
})?;
|
||||
Ok((source, target, flags))
|
||||
}
|
||||
}
|
||||
|
||||
/// A nul byte inside a path is a descriptor that cannot be carried out under
|
||||
/// any flags. Refused by name rather than silently emptied: an empty path turns
|
||||
/// up later as a mount failure about something else entirely.
|
||||
fn c_string(text: &str, what: &str) -> Result<CString, Failure> {
|
||||
CString::new(text).map_err(|_| Failure::new(format!("{what} contains a nul byte: {text:?}")))
|
||||
}
|
||||
|
||||
/// A failure names the path, which is what makes it actionable: a permission
|
||||
@@ -451,10 +590,6 @@ fn failed(share: &Mount, error: io::Error) -> Failure {
|
||||
Failure::new(format!("{}: {error}", share.at))
|
||||
}
|
||||
|
||||
fn failed_drive(drive: &Drive, error: io::Error) -> Failure {
|
||||
Failure::new(format!("{}: {error}", drive.at))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -578,28 +713,83 @@ mod tests {
|
||||
assert_eq!(flags & libc::MS_RDONLY, 0);
|
||||
}
|
||||
|
||||
/// The drive carries the same guard every share carries.
|
||||
///
|
||||
/// It is the mount that most needs it: a share is a directory this host
|
||||
/// prepared, and a drive is a filesystem built out of whatever a CDN sent.
|
||||
fn overlay(at: &str) -> Overlay {
|
||||
Overlay {
|
||||
lower: "/dev/vdb".into(),
|
||||
upper: "/dev/vdc".into(),
|
||||
at: at.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers sit beside the overlay, never under it: anything mounted
|
||||
/// under `at` is hidden the moment the overlay covers it.
|
||||
#[test]
|
||||
fn a_drive_is_mounted_without_devices_or_setuid_but_can_still_execute() {
|
||||
let drive = Drive {
|
||||
dev: "/dev/vdb".into(),
|
||||
at: "/nestri/install".into(),
|
||||
};
|
||||
let (source, target, flags) = options_drive(&drive).unwrap();
|
||||
fn an_overlays_layers_sit_beside_it_and_the_options_name_them() {
|
||||
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
||||
assert_eq!(plan.lower.to_str().unwrap(), "/dev/vdb");
|
||||
assert_eq!(plan.upper.to_str().unwrap(), "/dev/vdc");
|
||||
assert_eq!(plan.lower_at.to_str().unwrap(), "/nestri/.install/lower");
|
||||
assert_eq!(plan.rw_at.to_str().unwrap(), "/nestri/.install/rw");
|
||||
assert_eq!(
|
||||
source.to_str().unwrap(),
|
||||
"/dev/vdb",
|
||||
"the device is the source"
|
||||
plan.overlay_data.to_str().unwrap(),
|
||||
"lowerdir=/nestri/.install/lower,\
|
||||
upperdir=/nestri/.install/rw/upper,\
|
||||
workdir=/nestri/.install/rw/work"
|
||||
);
|
||||
}
|
||||
|
||||
/// Every layer carries the guard every share carries, and none of them
|
||||
/// stops the game's own executable from running.
|
||||
///
|
||||
/// These are the mounts that most need it: a share is a directory this
|
||||
/// host prepared, and a build is a filesystem made out of whatever a CDN
|
||||
/// sent.
|
||||
#[test]
|
||||
fn every_layer_is_mounted_without_devices_or_setuid_but_can_still_execute() {
|
||||
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
||||
for flags in [plan.lower_flags, plan.upper_flags, plan.overlay_flags] {
|
||||
assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID);
|
||||
assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV);
|
||||
assert_eq!(flags & libc::MS_NOEXEC, 0);
|
||||
}
|
||||
assert_eq!(plan.lower_flags & libc::MS_RDONLY, libc::MS_RDONLY);
|
||||
assert_eq!(plan.upper_flags & libc::MS_RDONLY, 0);
|
||||
assert_eq!(plan.overlay_flags & libc::MS_RDONLY, 0);
|
||||
assert_eq!(plan.overlay_flags & libc::MS_NOATIME, libc::MS_NOATIME);
|
||||
}
|
||||
|
||||
/// overlayfs splits its options on commas and colons, so a path carrying
|
||||
/// one would stack a different directory than the one named.
|
||||
#[test]
|
||||
fn an_overlay_the_kernel_would_misread_is_refused_before_anything_mounts() {
|
||||
for at in [
|
||||
"/nestri/in,stall",
|
||||
"/nestri/in:stall",
|
||||
"/",
|
||||
"/nestri/ins\0tall",
|
||||
] {
|
||||
assert!(
|
||||
OverlayPlan::new(&overlay(at)).is_err(),
|
||||
"{at:?} was accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_overlay_step_names_the_overlay_the_step_and_the_path() {
|
||||
let plan = OverlayPlan::new(&overlay("/nestri/install")).unwrap();
|
||||
let failure = plan.failed(
|
||||
"the build image",
|
||||
&plan.lower_at,
|
||||
io::Error::from_raw_os_error(libc::ENODEV),
|
||||
);
|
||||
assert!(
|
||||
failure
|
||||
.reason
|
||||
.starts_with("/nestri/install: the build image: /nestri/.install/lower: "),
|
||||
"{}",
|
||||
failure.reason
|
||||
);
|
||||
assert_eq!(target.to_str().unwrap(), "/nestri/install");
|
||||
assert_eq!(flags & libc::MS_NOSUID, libc::MS_NOSUID);
|
||||
assert_eq!(flags & libc::MS_NODEV, libc::MS_NODEV);
|
||||
assert_eq!(flags & libc::MS_NOATIME, libc::MS_NOATIME);
|
||||
// The game's executable lives here.
|
||||
assert_eq!(flags & libc::MS_NOEXEC, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -652,7 +842,7 @@ pub mod double {
|
||||
/// only thing under test.
|
||||
pub struct Double {
|
||||
pub mounted: Vec<Vec<Mount>>,
|
||||
pub drives: Vec<Vec<Drive>>,
|
||||
pub overlays: Vec<Vec<Overlay>>,
|
||||
pub started: Vec<Exec>,
|
||||
pub stops: usize,
|
||||
pub mount_failure: Option<Failure>,
|
||||
@@ -676,7 +866,7 @@ pub mod double {
|
||||
fn new(exit: Exit, holds_until_stopped: bool) -> Self {
|
||||
Self {
|
||||
mounted: Vec::new(),
|
||||
drives: Vec::new(),
|
||||
overlays: Vec::new(),
|
||||
started: Vec::new(),
|
||||
stops: 0,
|
||||
mount_failure: None,
|
||||
@@ -697,8 +887,8 @@ pub mod double {
|
||||
}
|
||||
}
|
||||
|
||||
fn mount_drives(&mut self, drives: &[Drive]) -> Result<(), Failure> {
|
||||
self.drives.push(drives.to_vec());
|
||||
fn mount_overlays(&mut self, overlays: &[Overlay]) -> Result<(), Failure> {
|
||||
self.overlays.push(overlays.to_vec());
|
||||
match &self.mount_failure {
|
||||
Some(failure) => Err(failure.clone()),
|
||||
None => Ok(()),
|
||||
|
||||
@@ -50,7 +50,9 @@ fn alive(pid: i32) -> bool {
|
||||
async fn a_stack_that_goes_away_takes_its_services_with_it() {
|
||||
let waiters = Waiters::new();
|
||||
let mut stack = Stack::from_table(waiters, SLEEPERS);
|
||||
let up = stack.bring_up().expect("two sleeps did not start");
|
||||
let up = stack
|
||||
.bring_up(Default::default())
|
||||
.expect("two sleeps did not start");
|
||||
assert_eq!(up.len(), 2);
|
||||
|
||||
let pids = stack.pids();
|
||||
|
||||
Reference in New Issue
Block a user