diff --git a/src/qz/common/CachedObject.java b/src/qz/common/CachedObject.java new file mode 100644 index 000000000..8493d7a1e --- /dev/null +++ b/src/qz/common/CachedObject.java @@ -0,0 +1,97 @@ +package qz.common; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +/** + * A generic class that encapsulates an object for caching. The cached object + * will be refreshed automatically when accessed after its lifespan has expired. + * + * @param The type of object to be cached. + */ +public class CachedObject { + public static final long DEFAULT_LIFESPAN = 5000; // in milliseconds + T lastObject; + Supplier supplier; + private long timestamp; + private long lifespan; + + /** + * Creates a new CachedObject with a default lifespan of 5000 milliseconds + * + * @param supplier The function to pull new values from + */ + public CachedObject(Supplier supplier) { + this(supplier, DEFAULT_LIFESPAN); + } + + /** + * Creates a new CachedObject + * + * @param supplier The function to pull new values from + * @param lifespan The lifespan of the cached object in milliseconds + */ + public CachedObject(Supplier supplier, long lifespan) { + this.supplier = supplier; + setLifespan(lifespan); + timestamp = Long.MIN_VALUE; // System.nanoTime() can be negative, MIN_VALUE guarantees a first-run. + } + + /** + * Registers a new supplier for the CachedObject + * + * @param supplier The function to pull new values from + */ + @SuppressWarnings("unused") + public void registerSupplier(Supplier supplier) { + this.supplier = supplier; + } + + /** + * Sets the lifespan of the cached object + * + * @param milliseconds The lifespan of the cached object in milliseconds + */ + public void setLifespan(long milliseconds) { + lifespan = Math.max(0, milliseconds); // prevent overflow + } + + /** + * Retrieves the cached object. + * If the cached object's lifespan has expired, it gets refreshed before being returned. + * + * @return The cached object + */ + public T get() { + return get(false); + } + + /** + * Retrieves the cached object. + * If the cached object's lifespan is expired or forceRefresh is true, it gets refreshed before being returned. + * + * @param forceRefresh If true, the cached object will be refreshed before being returned regardless of its lifespan + * @return The cached object + */ + public T get(boolean forceRefresh) { + long now = TimeUnit.NANOSECONDS.toMillis(System.nanoTime()); + // check lifespan + if (forceRefresh || (timestamp + lifespan <= now)) { + timestamp = now; + lastObject = supplier.get(); + } + return lastObject; + } + + // Test + public static void main(String ... args) throws InterruptedException { + final AtomicInteger testInt = new AtomicInteger(0); + + CachedObject cachedString = new CachedObject<>(testInt::incrementAndGet); + for(int i = 0; i < 100; i++) { + Thread.sleep(1500); + System.out.println(cachedString.get()); + } + } +} diff --git a/src/qz/printer/PrintServiceMatcher.java b/src/qz/printer/PrintServiceMatcher.java index e77a29d42..9f5292971 100644 --- a/src/qz/printer/PrintServiceMatcher.java +++ b/src/qz/printer/PrintServiceMatcher.java @@ -15,6 +15,7 @@ import org.codehaus.jettison.json.JSONArray; import org.codehaus.jettison.json.JSONException; import org.codehaus.jettison.json.JSONObject; +import qz.printer.info.CachedPrintServiceLookup; import qz.printer.info.NativePrinter; import qz.printer.info.NativePrinterMap; import qz.utils.SystemUtilities; @@ -28,14 +29,28 @@ public class PrintServiceMatcher { private static final Logger log = LogManager.getLogger(PrintServiceMatcher.class); + // PrintService is slow in CUPS, use a cache instead per JDK-7001133 + // TODO: Include JDK version test for caching when JDK-7001133 is fixed upstream + private static final boolean useCache = SystemUtilities.isUnix(); + public static NativePrinterMap getNativePrinterList(boolean silent, boolean withAttributes) { NativePrinterMap printers = NativePrinterMap.getInstance(); - printers.putAll(PrintServiceLookup.lookupPrintServices(null, null)); + printers.putAll(true, lookupPrintServices()); if (withAttributes) { printers.values().forEach(NativePrinter::getDriverAttributes); } if (!silent) { log.debug("Found {} printers", printers.size()); } return printers; } + private static PrintService[] lookupPrintServices() { + return useCache ? CachedPrintServiceLookup.lookupPrintServices() : + PrintServiceLookup.lookupPrintServices(null, null); + } + + private static PrintService lookupDefaultPrintService() { + return useCache ? CachedPrintServiceLookup.lookupDefaultPrintService() : + PrintServiceLookup.lookupDefaultPrintService(); + } + public static NativePrinterMap getNativePrinterList(boolean silent) { return getNativePrinterList(silent, false); } @@ -45,7 +60,7 @@ public static NativePrinterMap getNativePrinterList() { } public static NativePrinter getDefaultPrinter() { - PrintService defaultService = PrintServiceLookup.lookupDefaultPrintService(); + PrintService defaultService = lookupDefaultPrintService(); if(defaultService == null) { return null; @@ -53,7 +68,7 @@ public static NativePrinter getDefaultPrinter() { NativePrinterMap printers = NativePrinterMap.getInstance(); if (!printers.contains(defaultService)) { - printers.putAll(defaultService); + printers.putAll(false, defaultService); } return printers.get(defaultService); @@ -81,6 +96,8 @@ public static NativePrinter matchPrinter(String printerSearch, boolean silent) { if (!silent) { log.debug("Searching for PrintService matching {}", printerSearch); } + // Fix for https://github.com/qzind/tray/issues/931 + // This is more than an optimization, removal will lead to a regression NativePrinter defaultPrinter = getDefaultPrinter(); if (defaultPrinter != null && printerSearch.equals(defaultPrinter.getName())) { if (!silent) { log.debug("Matched default printer, skipping further search"); } @@ -153,7 +170,7 @@ public static NativePrinter matchPrinter(String printerSearch) { public static JSONArray getPrintersJSON(boolean includeDetails) throws JSONException { JSONArray list = new JSONArray(); - PrintService defaultService = PrintServiceLookup.lookupDefaultPrintService(); + PrintService defaultService = lookupDefaultPrintService(); boolean mediaTrayCrawled = false; diff --git a/src/qz/printer/info/CachedPrintService.java b/src/qz/printer/info/CachedPrintService.java new file mode 100644 index 000000000..83408e378 --- /dev/null +++ b/src/qz/printer/info/CachedPrintService.java @@ -0,0 +1,133 @@ +package qz.printer.info; + +import qz.common.CachedObject; + +import javax.print.*; +import javax.print.attribute.Attribute; +import javax.print.attribute.AttributeSet; +import javax.print.attribute.PrintServiceAttribute; +import javax.print.attribute.PrintServiceAttributeSet; +import javax.print.event.PrintServiceAttributeListener; +import java.util.HashMap; +import java.util.function.Supplier; + +/** + * PrintService.getName() is slow, and gets increasingly slower the more times it's called. + * + * By overriding and caching the PrintService attributes, we're able to help suppress/delay the + * performance loss of this bug. + * + * See also JDK-7001133 + */ +public class CachedPrintService implements PrintService { + private final PrintService printService; + private final long lifespan; + private final CachedObject cachedName; + private final CachedObject cachedAttributeSet; + private final HashMap, CachedObject> cachedAttributes = new HashMap<>(); + + public CachedPrintService(PrintService printService, long lifespan) { + this.printService = printService; + this.lifespan = lifespan; + cachedName = new CachedObject<>(this.printService::getName, lifespan); + cachedAttributeSet = new CachedObject<>(this.printService::getAttributes, lifespan); + } + + public CachedPrintService(PrintService printService) { + this(printService, CachedObject.DEFAULT_LIFESPAN); + } + + @Override + public String getName() { + return cachedName.get(); + } + + @Override + public DocPrintJob createPrintJob() { + return printService.createPrintJob(); + } + + @Override + public void addPrintServiceAttributeListener(PrintServiceAttributeListener listener) { + printService.addPrintServiceAttributeListener(listener); + } + + @Override + public void removePrintServiceAttributeListener(PrintServiceAttributeListener listener) { + printService.removePrintServiceAttributeListener(listener); + } + + @Override + public PrintServiceAttributeSet getAttributes() { + return cachedAttributeSet.get(); + } + + public PrintService getJavaxPrintService() { + return printService; + } + + @Override + public T getAttribute(Class category) { + if (!cachedAttributes.containsKey(category)) { + Supplier supplier = () -> printService.getAttribute(category); + CachedObject cachedObject = new CachedObject<>(supplier, lifespan); + cachedAttributes.put(category, cachedObject); + } + return category.cast(cachedAttributes.get(category).get()); + } + + @Override + public DocFlavor[] getSupportedDocFlavors() { + return printService.getSupportedDocFlavors(); + } + + @Override + public boolean isDocFlavorSupported(DocFlavor flavor) { + return printService.isDocFlavorSupported(flavor); + } + + @Override + public Class[] getSupportedAttributeCategories() { + return printService.getSupportedAttributeCategories(); + } + + @Override + public boolean isAttributeCategorySupported(Class category) { + return printService.isAttributeCategorySupported(category); + } + + @Override + public Object getDefaultAttributeValue(Class category) { + return printService.getDefaultAttributeValue(category); + } + + @Override + public Object getSupportedAttributeValues(Class category, DocFlavor flavor, AttributeSet attributes) { + return printService.getSupportedAttributeValues(category, flavor, attributes); + } + + @Override + public boolean isAttributeValueSupported(Attribute attrval, DocFlavor flavor, AttributeSet attributes) { + return printService.isAttributeValueSupported(attrval, flavor, attributes); + } + + @Override + public AttributeSet getUnsupportedAttributes(DocFlavor flavor, AttributeSet attributes) { + return printService.getUnsupportedAttributes(flavor, attributes); + } + + @Override + public ServiceUIFactory getServiceUIFactory() { + return printService.getServiceUIFactory(); + } + + public boolean equals(Object obj) { + return (obj == this || + (obj instanceof PrintService && + ((PrintService)obj).getName().equals(getName()))); + } + + public int hashCode() { + return this.getClass().hashCode()+getName().hashCode(); + } +} diff --git a/src/qz/printer/info/CachedPrintServiceLookup.java b/src/qz/printer/info/CachedPrintServiceLookup.java new file mode 100644 index 000000000..248527b29 --- /dev/null +++ b/src/qz/printer/info/CachedPrintServiceLookup.java @@ -0,0 +1,81 @@ +package qz.printer.info; + +import qz.common.CachedObject; + +import javax.print.PrintService; +import javax.print.PrintServiceLookup; + +/** + * PrintService[] cache to workaround JDK-7001133 + * + * See also CachedPrintService + */ +public class CachedPrintServiceLookup { + private static final CachedObject cachedDefault = new CachedObject<>(CachedPrintServiceLookup::wrapDefaultPrintService); + private static final CachedObject cachedPrintServices = new CachedObject<>(CachedPrintServiceLookup::wrapPrintServices); + + // Keep CachedPrintService object references between calls to supplier + private static CachedPrintService[] cachedPrintServicesCopy = {}; + + static { + setLifespan(CachedObject.DEFAULT_LIFESPAN); + } + + public static PrintService lookupDefaultPrintService() { + return cachedDefault.get(); + } + + public static void setLifespan(long lifespan) { + cachedDefault.setLifespan(lifespan); + cachedPrintServices.setLifespan(lifespan); + } + + public static PrintService[] lookupPrintServices() { + return cachedPrintServices.get(); + } + + private static CachedPrintService wrapDefaultPrintService() { + PrintService javaxPrintService = PrintServiceLookup.lookupDefaultPrintService(); + // CachedObject's supplier returns null + if(javaxPrintService == null) { + return null; + } + // If this CachedPrintService already exists, reuse it rather than wrapping a new one + CachedPrintService cachedPrintService = getMatch(cachedPrintServicesCopy, javaxPrintService); + if (cachedPrintService == null) { + // Wrap a new one + cachedPrintService = new CachedPrintService(javaxPrintService); + } + return cachedPrintService; + } + + private static CachedPrintService[] wrapPrintServices() { + PrintService[] javaxPrintServices = PrintServiceLookup.lookupPrintServices(null, null); + CachedPrintService[] cachedPrintServices = new CachedPrintService[javaxPrintServices.length]; + for (int i = 0; i < javaxPrintServices.length; i++) { + // If this CachedPrintService already exists, reuse it rather than wrapping a new one + cachedPrintServices[i] = getMatch(cachedPrintServicesCopy, javaxPrintServices[i]); + if (cachedPrintServices[i] == null) { + cachedPrintServices[i] = new CachedPrintService(javaxPrintServices[i]); + } + } + cachedPrintServicesCopy = cachedPrintServices; + // Immediately invalidate the defaultPrinter cache + cachedDefault.get(true); + + return cachedPrintServices; + } + + private static CachedPrintService getMatch(CachedPrintService[] array, PrintService javaxPrintService) { + if(array != null) { + for(CachedPrintService cps : array) { + // Note: Order of operations can cause the defaultService pointer to differ if lookupDefaultPrintService() + // is called before lookupPrintServices(...) because the provider will invalidate on refreshServices() if + // "sun.java2d.print.polling" is set to "false". We're OK with this because worst case, we just + // call "lpstat" a second time. + if (cps.getJavaxPrintService() == javaxPrintService) return cps; + } + } + return null; + } +} diff --git a/src/qz/printer/info/CupsPrinterMap.java b/src/qz/printer/info/CupsPrinterMap.java index 8ee97ae7c..ce94492f9 100644 --- a/src/qz/printer/info/CupsPrinterMap.java +++ b/src/qz/printer/info/CupsPrinterMap.java @@ -18,8 +18,8 @@ public class CupsPrinterMap extends NativePrinterMap { private static final Logger log = LogManager.getLogger(CupsPrinterMap.class); private Map> resolutionMap = new HashMap<>(); - public synchronized NativePrinterMap putAll(PrintService... services) { - ArrayList missing = findMissing(services); + public synchronized NativePrinterMap putAll(boolean exhaustive, PrintService... services) { + ArrayList missing = findMissing(exhaustive, services); if (missing.isEmpty()) { return this; } String output = "\n" + ShellUtilities.executeRaw(new String[] {"lpstat", "-l", "-p"}); diff --git a/src/qz/printer/info/NativePrinter.java b/src/qz/printer/info/NativePrinter.java index 85b64f832..9c663e2ed 100644 --- a/src/qz/printer/info/NativePrinter.java +++ b/src/qz/printer/info/NativePrinter.java @@ -57,10 +57,6 @@ public boolean isNull() { @Override public boolean equals(Object o) { - // PrintService.equals(...) is very slow in CUPS; use the pointer - if (SystemUtilities.isUnix() && value instanceof PrintService) { - return o == value; - } if (value != null) { return value.equals(o); } @@ -69,6 +65,7 @@ public boolean equals(Object o) { } private final String printerId; + private boolean outdated; private PrinterProperty description; private PrinterProperty printService; diff --git a/src/qz/printer/info/NativePrinterMap.java b/src/qz/printer/info/NativePrinterMap.java index 15fb0db59..b30fb1569 100644 --- a/src/qz/printer/info/NativePrinterMap.java +++ b/src/qz/printer/info/NativePrinterMap.java @@ -15,7 +15,7 @@ public abstract class NativePrinterMap extends ConcurrentHashMap findMissing(PrintService[] services) { + /** + * WARNING: Despite the function's name, if exhaustive is true, it will treat the listing as exhaustive and remove + * any PrintServices that are not part of this HashMap. + */ + public ArrayList findMissing(boolean exhaustive, PrintService[] services) { ArrayList serviceList = new ArrayList<>(Arrays.asList(services)); // shrinking list drastically improves performance + for(NativePrinter printer : values()) { if (serviceList.contains(printer.getPrintService())) { serviceList.remove(printer.getPrintService()); // existing match } else { - printer.setOutdated(true); // no matches, mark to be removed + if(exhaustive) { + printer.setOutdated(true); // no matches, mark to be removed + } } } @@ -59,6 +66,7 @@ public ArrayList findMissing(PrintService[] services) { remove(entry.getKey()); } } + // any remaining services are new/missing return serviceList; } diff --git a/src/qz/printer/info/WindowsPrinterMap.java b/src/qz/printer/info/WindowsPrinterMap.java index 0e7df7aab..628315b9e 100644 --- a/src/qz/printer/info/WindowsPrinterMap.java +++ b/src/qz/printer/info/WindowsPrinterMap.java @@ -12,8 +12,8 @@ public class WindowsPrinterMap extends NativePrinterMap { private static final Logger log = LogManager.getLogger(WindowsPrinterMap.class); - public synchronized NativePrinterMap putAll(PrintService... services) { - for(PrintService service : findMissing(services)) { + public synchronized NativePrinterMap putAll(boolean exhaustive, PrintService... services) { + for(PrintService service : findMissing(exhaustive, services)) { String name = service.getName(); if (name.equals("PageManager PDF Writer")) { log.warn("Printer \"{}\" is blacklisted, removing", name); // Per https://github.com/qzind/tray/issues/599