diff --git a/crates/print/src/windows.rs b/crates/print/src/windows.rs index c4a753a9..0c8bbb43 100644 --- a/crates/print/src/windows.rs +++ b/crates/print/src/windows.rs @@ -11,6 +11,7 @@ //! and WinRT calls, so there is no `unsafe` here. use std::io::Write as _; +use std::sync::{Mutex, MutexGuard, PoisonError}; use winprint::printer::{PrinterDevice, WinPdfPrinter}; use winprint::ticket::document::{NS_PSK, OwnedName, PrintFeature, PrintTicketDocument, reader::ParsableXmlDocument as _}; @@ -29,6 +30,18 @@ fn default_printer() -> Option { winsafe::GetDefaultPrinter().ok().filter(|n| !n.is_empty()) } +/// Keeps two threads of this process out of the Windows PrintTicket provider (`PTOpenProvider` +/// and the calls on its handle) at the same time. When two threads make the process's first calls +/// together, winspool can keep a pointer to a device capabilities cache it has freed, and a later +/// call crashes the process. +static PRINT_TICKET_PROVIDER: Mutex<()> = Mutex::new(()); + +/// Holds [`PRINT_TICKET_PROVIDER`] until the guard is dropped. A lock that a panicking thread +/// held is taken like any other. +fn print_ticket_provider() -> MutexGuard<'static, ()> { + PRINT_TICKET_PROVIDER.lock().unwrap_or_else(PoisonError::into_inner) +} + pub(crate) fn printers() -> Vec { match PrinterDevice::all() { Ok(devices) => crate::printer_list(devices.iter().map(|d| (d.name().to_string(), two_sided(d))), default_printer().as_deref()), @@ -40,7 +53,11 @@ pub(crate) fn printers() -> Vec { /// option for the feature the job's ticket sets. `None` when the capabilities can't be read. A /// driver without one cannot confirm an explicit two-sided request, which is refused before submission. fn two_sided(device: &PrinterDevice) -> Option { - let capabilities = PrintCapabilities::fetch(device).ok()?; + let capabilities = { + let _provider = print_ticket_provider(); + PrintCapabilities::fetch(device) + } + .ok()?; let duplex = OwnedName::qualified("JobDuplexAllDocumentsContiguously", NS_PSK, Some("psk")); Some(capabilities.options_for_feature(duplex).any(|o| o.name.as_ref().is_some_and(|n| n.local_name.starts_with("TwoSided")))) } @@ -48,10 +65,11 @@ fn two_sided(device: &PrinterDevice) -> Option { /// What the driver's PrintCapabilities offer for the job options [`ticket::verify_job_options`] /// checks; all unknown when they can't be read. fn driver_offers(device: &PrinterDevice) -> ticket::Offered { - PrintCapabilities::fetch_xml_for_ticket(device, None) - .ok() - .and_then(|xml| String::from_utf8(xml).ok()) - .map_or_else(ticket::Offered::default, |xml| ticket::offered(&xml)) + let xml = { + let _provider = print_ticket_provider(); + PrintCapabilities::fetch_xml_for_ticket(device, None) + }; + xml.ok().and_then(|xml| String::from_utf8(xml).ok()).map_or_else(ticket::Offered::default, |xml| ticket::offered(&xml)) } /// The queue the job goes to: the one asked for, else the default. Never another one: a job @@ -72,7 +90,11 @@ fn select_device(devices: Vec, requested: Option<&str>, default: /// driver changed the paper, the orientation or the scaling: then nothing is sent, rather than a /// job at the wrong size. fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape: bool) -> Result<(PrintTicket, (u32, u32)), Error> { - let capabilities = PrintCapabilities::fetch(device).map_err(spool_error)?; + let capabilities = { + let _provider = print_ticket_provider(); + PrintCapabilities::fetch(device) + } + .map_err(spool_error)?; let medias: Vec = capabilities.page_media_sizes().collect(); let papers: Vec> = medias .iter() @@ -110,9 +132,14 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape document.features.retain(|f| !(f.name.local_name == "PageScaling" && f.name.namespace_ref() == Some(NS_PSK))); document.features.push(PrintFeature { name: scaling_name, properties: Vec::new(), options: vec![no_scaling.clone()], features: Vec::new() }); } - let mut builder = PrintTicketBuilder::new(device).map_err(spool_error)?; - builder.merge(PrintTicket::from(document)).map_err(spool_error)?; - let validated = in_drivers_spelling(device, builder.build().map_err(spool_error)?); + let built = { + let _provider = print_ticket_provider(); + // The builder closes its provider when it is dropped, before the guard. + let mut builder = PrintTicketBuilder::new(device).map_err(spool_error)?; + builder.merge(PrintTicket::from(document)).map_err(spool_error)?; + builder.build().map_err(spool_error)? + }; + let validated = in_drivers_spelling(device, built); let xml = std::str::from_utf8(validated.get_xml()).map_err(spool_error)?; let paper = ticket::verify_ticket(xml, wanted, landscape)?; Ok((validated, paper)) @@ -125,6 +152,7 @@ fn driver_ticket(device: &PrinterDevice, job: &Job, sheet: (f64, f64), landscape /// (an empty one, validated). The ticket as it is when they can't be read. fn in_drivers_spelling(device: &PrinterDevice, ticket: PrintTicket) -> PrintTicket { let spelled = (|| { + let _provider = print_ticket_provider(); let mut builder = PrintTicketBuilder::new(device).ok()?; builder.merge(PrintTicket::from_xml(ticket::empty_ticket())).ok()?; let own = builder.build().ok()?; @@ -135,11 +163,11 @@ fn in_drivers_spelling(device: &PrinterDevice, ticket: PrintTicket) -> PrintTick /// The widest unprintable margin of the ticket's paper, in points; 0 when the driver doesn't say. fn imageable_margin(device: &PrinterDevice, ticket: &PrintTicket) -> f64 { - PrintCapabilities::fetch_xml_for_ticket(device, Some(ticket)) - .ok() - .and_then(|xml| String::from_utf8(xml).ok()) - .and_then(|xml| ticket::imageable_margin(&xml)) - .map_or(0.0, ticket::points) + let xml = { + let _provider = print_ticket_provider(); + PrintCapabilities::fetch_xml_for_ticket(device, Some(ticket)) + }; + xml.ok().and_then(|xml| String::from_utf8(xml).ok()).and_then(|xml| ticket::imageable_margin(&xml)).map_or(0.0, ticket::points) } /// Spool a print-ready PDF. The sheets are read first ([`sheets::read`]); everything that talks @@ -216,8 +244,12 @@ pub(crate) fn submit_with_progress(pdf: &[u8], job: &Job, progress: &mut dyn FnM let printer = WinPdfPrinter::new(device); let jobs = jobs as usize; for copy in 0..jobs { + // winprint converts the ticket to a DEVMODE through the provider before each job's + // first progress report, which releases the guard. + let mut provider = Some(print_ticket_provider()); printer .print_with_progress(&path, ticket.clone(), |processed, total| { + drop(provider.take()); // Page acceptance is distinct from terminal job success. The callback // drains on the submitter's thread, so it can retain non-Send UI state. let (processed, total) = (processed as usize, total as usize); @@ -256,6 +288,41 @@ mod tests { assert!(select_device(Vec::new(), None, None).is_err()); } + #[test] + fn one_thread_at_a_time_is_in_the_print_ticket_provider() { + use std::sync::Barrier; + use std::sync::atomic::{AtomicUsize, Ordering}; + const THREADS: usize = 12; + let inside = AtomicUsize::new(0); + let most = AtomicUsize::new(0); + let start = Barrier::new(THREADS); + std::thread::scope(|s| { + for _ in 0..THREADS { + s.spawn(|| { + start.wait(); + for _ in 0..20 { + let _provider = print_ticket_provider(); + most.fetch_max(inside.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst); + std::thread::sleep(std::time::Duration::from_millis(1)); + inside.fetch_sub(1, Ordering::SeqCst); + } + }); + } + }); + assert_eq!(most.load(Ordering::SeqCst), 1); + } + + #[test] + fn a_panic_inside_the_print_ticket_provider_does_not_lock_it_out() { + let panicked = std::thread::spawn(|| { + let _provider = print_ticket_provider(); + panic!("a driver call panicked"); + }) + .join(); + assert!(panicked.is_err()); + drop(print_ticket_provider()); + } + /// What a real driver makes of a job's ticket: Letter, portrait, two copies. Checks explicit /// option retention separately; unsupported options must fail before submission. Sends no job. /// `CRAFT_PRINT_TEST_PRINTER` names the queue; @@ -271,9 +338,12 @@ mod tests { assert_eq!(paper, (215_900, 279_400)); // The ticket names the driver's vendor namespaces as the driver does (the merge renamed them). // `respell` returning `None` would not show it: it also does when it refused the ticket. - let mut own = PrintTicketBuilder::new(&device).unwrap(); - own.merge(PrintTicket::from_xml(ticket::empty_ticket())).unwrap(); - let own = own.build().unwrap(); + let own = { + let _provider = print_ticket_provider(); + let mut own = PrintTicketBuilder::new(&device).unwrap(); + own.merge(PrintTicket::from_xml(ticket::empty_ticket())).unwrap(); + own.build().unwrap() + }; let unlike = ticket::prefixes_unlike_the_drivers(std::str::from_utf8(ticket.get_xml()).unwrap(), std::str::from_utf8(own.get_xml()).unwrap()).unwrap(); assert!(unlike.is_empty(), "vendor namespaces under another prefix than the driver's (namespace, ticket, driver): {unlike:?}"); @@ -281,7 +351,11 @@ mod tests { if let Ok(path) = std::env::var("CRAFT_PRINT_TEST_TICKET_OUTPUT") { std::fs::write(path, ticket.get_xml()).unwrap(); } - let capabilities = PrintCapabilities::fetch(&device).unwrap(); + let capabilities = { + let _provider = print_ticket_provider(); + PrintCapabilities::fetch(&device) + } + .unwrap(); for (feature, wanted, requested) in [ ("PageOutputColor", "Monochrome", Job { grayscale: true, ..Job::default() }), ("JobDuplexAllDocumentsContiguously", "TwoSidedLongEdge", Job { duplex: Some(crate::Duplex::LongEdge), ..Job::default() }), @@ -337,9 +411,12 @@ mod tests { } let name = std::env::var("CRAFT_PRINT_TEST_PRINTER").unwrap(); let device = select_device(PrinterDevice::all().unwrap(), Some(&name), None).unwrap(); - let mut builder = PrintTicketBuilder::new(&device).unwrap(); - builder.merge(PrintTicket::from_xml(winprint::ticket::DEFAULT_PRINT_TICKET_XML.as_bytes().to_vec())).unwrap(); - let preferences = colour(&builder.build().unwrap()); + let preferences = { + let _provider = print_ticket_provider(); + let mut builder = PrintTicketBuilder::new(&device).unwrap(); + builder.merge(PrintTicket::from_xml(winprint::ticket::DEFAULT_PRINT_TICKET_XML.as_bytes().to_vec())).unwrap(); + colour(&builder.build().unwrap()) + }; eprintln!("the preferences' colour: {preferences:?}"); let (ticket, _) = driver_ticket(&device, &Job::default(), (595.28, 841.89), false).unwrap(); assert_eq!(colour(&ticket), preferences, "a job without grayscale keeps the preferences' colour");