From 0b55ce1e491c27a80dc3c53d68dff7d8d7747953 Mon Sep 17 00:00:00 2001 From: "Flavio S. Glock" Date: Wed, 12 Aug 2026 22:49:15 +0200 Subject: [PATCH] fix: unblock CPAN XML and AnyEvent dependency chains Add transitive bundled-provider requirements and a JAXP-backed XML::LibXSLT provider so Catmandu::CrossRef can install without native libxslt or distribution preferences. Correct nonblocking socket connection handling, managed fcntl behavior, anonymous lvalue descriptor ownership, and weakly registered watcher cycles so AnyEvent::SMTP can complete its loopback test. Generated with [Codex](https://openai.com/codex) Co-Authored-By: Codex --- docs/about/changelog.md | 4 + docs/reference/bundled-modules.md | 1 + docs/reference/feature-matrix.md | 1 + .../org/perlonjava/runtime/io/SocketIO.java | 23 ++- .../runtime/operators/IOOperator.java | 37 ++-- .../runtime/perlmodule/XMLLibXML.java | 4 +- .../runtime/perlmodule/XMLLibXSLT.java | 181 ++++++++++++++++++ .../runtime/runtimetypes/DestroyDispatch.java | 4 + .../runtimetypes/ReachabilityWalker.java | 29 +++ .../runtime/runtimetypes/RuntimeGlob.java | 8 +- .../runtime/runtimetypes/RuntimeScalar.java | 52 +---- src/main/perl/lib/CPAN/Distribution.pm | 23 +++ .../perl/lib/PerlOnJava/ProviderManifest.pm | 11 ++ src/main/perl/lib/PerlOnJava/providers.json | 16 +- src/main/perl/lib/XML/LibXSLT.pm | 56 ++++++ .../resources/module/XML-LibXSLT/t/basic.t | 27 +++ .../resources/unit/cpan_bundled_providers.t | 23 ++- .../unit/socket_nonblocking_getpeername.t | 39 ++++ .../unit/weak_blessed_watcher_cycle.t | 28 +++ src/test/resources/unit/xml_libxslt.t | 21 ++ 20 files changed, 504 insertions(+), 84 deletions(-) create mode 100644 src/main/java/org/perlonjava/runtime/perlmodule/XMLLibXSLT.java create mode 100644 src/main/perl/lib/XML/LibXSLT.pm create mode 100644 src/test/resources/module/XML-LibXSLT/t/basic.t create mode 100644 src/test/resources/unit/socket_nonblocking_getpeername.t create mode 100644 src/test/resources/unit/weak_blessed_watcher_cycle.t create mode 100644 src/test/resources/unit/xml_libxslt.t diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 589388f43..f84f13251 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -4,6 +4,10 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans. ## Work in progress +- CPAN/compiler tooling: add transitive prerequisites to the bundled-provider + manifest, provide a JAXP-backed `XML::LibXSLT`, and preserve descriptors for + anonymous handles stored in container lvalues. This unblocks + `Catmandu::CrossRef` and `AnyEvent::SMTP` without distribution preferences. - Add Perl interpreter multiplicity and the supported ithread tranche across the JVM and interpreter backends. Mutable execution state is owned by independent `PerlRuntime` instances; child threads receive identity-aware diff --git a/docs/reference/bundled-modules.md b/docs/reference/bundled-modules.md index 6a901e5c1..3f7369e6d 100644 --- a/docs/reference/bundled-modules.md +++ b/docs/reference/bundled-modules.md @@ -327,6 +327,7 @@ These are loaded automatically or via `use`: |--------|---------------|-------| | `XML::Parser` | Perl | | | `XML::Parser::Expat` | Java | Uses Java SAX parser | +| `XML::LibXSLT` | Java + Perl | Core XSLT transformation API backed by JDK JAXP; requires bundled `XML::LibXML` | | `HTML::Parser` | Java | | | `HTML::Content::Extractor` | Java | Uses jsoup for HTML5 parsing; install the CPAN `.pm` with `jcpan` | diff --git a/docs/reference/feature-matrix.md b/docs/reference/feature-matrix.md index 0c6cc37be..d915c330d 100644 --- a/docs/reference/feature-matrix.md +++ b/docs/reference/feature-matrix.md @@ -802,6 +802,7 @@ The `:encoding()` layer supports all encodings provided by Java's `Charset.forNa - ✅ **Text::CSV** module. - ✅ **TOML** module. - ✅ **XML::Parser** module backed by JDK SAX (replaces native libexpat XS). +- ✅ **XML::LibXSLT** core transformation API backed by JDK JAXP (replaces native libxslt XS). - ✅ **YAML::PP** module. - ✅ **YAML** module. - ✅ **YAML::Syck** compatibility module backed by bundled `YAML::PP`. diff --git a/src/main/java/org/perlonjava/runtime/io/SocketIO.java b/src/main/java/org/perlonjava/runtime/io/SocketIO.java index 9983098bd..0957eb733 100644 --- a/src/main/java/org/perlonjava/runtime/io/SocketIO.java +++ b/src/main/java/org/perlonjava/runtime/io/SocketIO.java @@ -130,7 +130,11 @@ private void initializeInternetSocketStreams() { } try { this.socket = socketChannel.socket(); - if (socketChannel.isConnected()) { + // java.net.Socket streams cannot be obtained while their channel is + // non-blocking (IllegalBlockingModeException). Non-blocking sockets + // use SocketChannel reads/writes below and initialize streams only + // if they later transition back to blocking mode. + if (socketChannel.isConnected() && socketChannel.isBlocking()) { this.inputStream = socket.getInputStream(); this.outputStream = socket.getOutputStream(); } @@ -257,9 +261,7 @@ public RuntimeScalar connect(String address, int port) { boolean finished = socketChannel.finishConnect(); if (finished) { // Connection completed — return EISCONN to match POSIX behavior - this.socket = socketChannel.socket(); - this.inputStream = socket.getInputStream(); - this.outputStream = socket.getOutputStream(); + initializeInternetSocketStreams(); getGlobalVariable("main::!").set(ErrnoVariable.EISCONN()); return scalarUndef; } @@ -307,9 +309,7 @@ public RuntimeScalar connect(String address, int port) { return scalarUndef; } // Connected immediately - this.socket = socketChannel.socket(); - this.inputStream = socket.getInputStream(); - this.outputStream = socket.getOutputStream(); + initializeInternetSocketStreams(); return scalarTrue; } @@ -1084,6 +1084,15 @@ public RuntimeScalar getsockname() { */ public RuntimeScalar getpeername() { try { + // A non-blocking connect is only finalized in Java after + // finishConnect(). POSIX callers commonly wait for write readiness + // and then use getpeername() to distinguish success from failure + // (AnyEvent::Socket does exactly this), so complete the pending + // connection at this observation point just as read/write do. + if (!ensureConnected()) { + getGlobalVariable("main::!").set(ErrnoVariable.EAGAIN()); + return scalarUndef; + } if (socketChannel != null && socketChannel.getRemoteAddress() instanceof UnixDomainSocketAddress unixAddress) { return packSockaddrUn(unixAddress); diff --git a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java index b3b43bb26..8fa449d29 100644 --- a/src/main/java/org/perlonjava/runtime/operators/IOOperator.java +++ b/src/main/java/org/perlonjava/runtime/operators/IOOperator.java @@ -675,8 +675,8 @@ public static RuntimeScalar open(int ctx, RuntimeBase... args) { RuntimeGlob anonGlob = new RuntimeGlob(null).setIO(oneFh); newGlob.value = anonGlob; RuntimeIO.registerGlobForFdRecycling(anonGlob, oneFh); - fileHandle.set(newGlob); - fileHandle.ioOwner = true; + RuntimeScalar assignedHandle = fileHandle.set(newGlob); + assignedHandle.ioOwner = true; } long pid = oneFh.getPid(); if (pid > 0) return new RuntimeScalar(pid); @@ -896,12 +896,8 @@ else if (secondArg.type == RuntimeScalarType.GLOB || secondArg.type == RuntimeSc // Register for GC-based fd recycling (mimics Perl's DESTROY on scope exit) RuntimeIO.registerGlobForFdRecycling(anonGlob, fh); // Use set() to modify the lvalue in place - fileHandle.set(newGlob); - // Mark this scalar as the IO owner so scopeExitCleanup will close - // the handle when the variable goes out of scope. Copies of this - // reference (via set()) won't have ioOwner=true, preventing - // premature close of shared handles (e.g., Test2's dup'd STDOUT). - fileHandle.ioOwner = true; + RuntimeScalar assignedHandle = fileHandle.set(newGlob); + assignedHandle.ioOwner = true; } long pid = fh.getPid(); if (pid > 0) return new RuntimeScalar(pid); @@ -1953,13 +1949,8 @@ public static RuntimeScalar socket(int ctx, RuntimeBase... args) { RuntimeGlob anonGlob = new RuntimeGlob(null).setIO(socketIO); newGlob.value = anonGlob; RuntimeIO.registerGlobForFdRecycling(anonGlob, socketIO); - socketHandle.set(newGlob); - // Mark this scalar as the IO owner so scopeExitCleanup will - // unregister the fd when the variable goes out of scope. - // Copies (via set()) won't have ioOwner=true, preventing - // premature fd unregistration when copies go out of scope - // (e.g., method argument copies in IO::Handle::fileno). - socketHandle.ioOwner = true; + RuntimeScalar assignedHandle = socketHandle.set(newGlob); + assignedHandle.ioOwner = true; } return scalarTrue; @@ -2166,7 +2157,7 @@ public static RuntimeScalar listen(int ctx, RuntimeBase... args) { /** * accept(NEWSOCKET, GENERICSOCKET) * Accepts a connection on a listening socket. - * Returns the packed sockaddr of the remote peer on success, false on failure. + * Returns the packed sockaddr of the remote peer on success, undef on failure. */ public static RuntimeScalar accept(int ctx, RuntimeBase... args) { if (args.length < 2) { @@ -2189,7 +2180,8 @@ public static RuntimeScalar accept(int ctx, RuntimeBase... args) { // Accept the connection - returns a new SocketIO for the client SocketIO clientSocketIO = listenSocketIO.acceptConnection(); if (clientSocketIO == null) { - return scalarFalse; + getGlobalVariable("main::!").set(ErrnoVariable.EAGAIN()); + return scalarUndef; } // Wrap in RuntimeIO and associate with the NEWSOCKET glob @@ -2424,6 +2416,17 @@ public static RuntimeScalar fcntl(int ctx, RuntimeBase... args) { return scalarUndef; } + IOHandle managedHandle = selectableHandle(fh.ioHandle); + if (managedHandle instanceof SocketIO socketIO) { + if (function == 3) { // F_GETFL + return new RuntimeScalar(socketIO.isBlocking() ? 0 : 2048); + } + if (function == 4) { // F_SETFL + socketIO.setBlocking((arg & 2048) == 0); + return scalarTrue; + } + } + // Get the file descriptor number RuntimeScalar filenoResult = fh.ioHandle.fileno(); int fd = filenoResult.getDefinedBoolean() ? filenoResult.getInt() : -1; diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/XMLLibXML.java b/src/main/java/org/perlonjava/runtime/perlmodule/XMLLibXML.java index 079d08c62..4e93081ec 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/XMLLibXML.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/XMLLibXML.java @@ -460,7 +460,7 @@ private static void setupISA() { // Node wrapping helpers // ================================================================ - static RuntimeScalar wrapNode(Node node) { + public static RuntimeScalar wrapNode(Node node) { if (node == null) return scalarUndef; RuntimeHash hash = new RuntimeHash(); hash.put(NODE_KEY, new RuntimeScalar(node)); @@ -485,7 +485,7 @@ private static ReaderState getReader(RuntimeScalar self) { throw new RuntimeException("Not a valid XML::LibXML::Reader object"); } - static Node getNode(RuntimeScalar self) { + public static Node getNode(RuntimeScalar self) { if (self == null || self.type == RuntimeScalarType.UNDEF) return null; RuntimeHash hash; try { hash = self.hashDerefRaw(); } diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/XMLLibXSLT.java b/src/main/java/org/perlonjava/runtime/perlmodule/XMLLibXSLT.java new file mode 100644 index 000000000..183709ac0 --- /dev/null +++ b/src/main/java/org/perlonjava/runtime/perlmodule/XMLLibXSLT.java @@ -0,0 +1,181 @@ +package org.perlonjava.runtime.perlmodule; + +import org.perlonjava.runtime.operators.ReferenceOperators; +import org.perlonjava.runtime.runtimetypes.*; +import org.w3c.dom.Document; +import org.w3c.dom.Node; + +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Templates; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMResult; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import javax.xml.transform.stream.StreamSource; +import java.io.StringReader; +import java.io.StringWriter; + +/** Java XS provider for the core XML::LibXSLT transformation surface. */ +public class XMLLibXSLT extends PerlModuleBase { + public static final String XS_VERSION = "2.003000"; + private static final String STATE_KEY = "_xslt_state"; + private static final String RESULT_KEY = "_xslt_result"; + + private record StylesheetState(Templates templates) {} + private record TextResult(String value) {} + + public XMLLibXSLT() { + super("XML::LibXSLT", false); + } + + public static void initialize() { + XMLLibXSLT module = new XMLLibXSLT(); + try { + module.registerMethod("INIT_THREAD_SUPPORT", "noop", null); + module.registerMethod("HAVE_EXSLT", "falseValue", null); + module.registerMethod("LIBXSLT_DOTTED_VERSION", "dottedVersion", null); + module.registerMethod("LIBXSLT_VERSION", "numericVersion", null); + module.registerMethod("LIBXSLT_RUNTIME_VERSION", "numericVersion", null); + module.registerMethod("_parse_stylesheet", "parseStylesheet", null); + module.registerMethod("_parse_stylesheet_file", "parseStylesheetFile", null); + module.registerMethodInPackage("XML::LibXSLT::Stylesheet", "transform", "transform"); + module.registerMethodInPackage("XML::LibXSLT::Stylesheet", "_output_string", "outputString"); + module.registerMethodInPackage("XML::LibXSLT::Stylesheet", "output_method", "outputMethod"); + module.registerMethodInPackage("XML::LibXSLT::Stylesheet", "output_encoding", "outputEncoding"); + module.registerMethodInPackage("XML::LibXSLT::Stylesheet", "media_type", "mediaType"); + } catch (NoSuchMethodException e) { + throw new RuntimeException(e); + } + } + + private static RuntimeScalar blessedState(String key, Object state, String packageName) { + RuntimeHash hash = new RuntimeHash(); + hash.put(key, new RuntimeScalar(state)); + return ReferenceOperators.bless( + hash.createReferenceWithTrackedElements(), new RuntimeScalar(packageName)); + } + + private static Object state(RuntimeScalar self, String key) { + RuntimeScalar value = self.hashDerefRaw().get(key); + if (value == null || value.value == null) { + throw new RuntimeException("Invalid XML::LibXSLT object"); + } + return value.value; + } + + public static RuntimeList noop(RuntimeArray args, int ctx) { + return new RuntimeScalar(1).getList(); + } + + public static RuntimeList falseValue(RuntimeArray args, int ctx) { + return new RuntimeScalar(0).getList(); + } + + public static RuntimeList dottedVersion(RuntimeArray args, int ctx) { + return new RuntimeScalar("JAXP").getList(); + } + + public static RuntimeList numericVersion(RuntimeArray args, int ctx) { + return new RuntimeScalar(0).getList(); + } + + public static RuntimeList parseStylesheet(RuntimeArray args, int ctx) { + try { + Node node = XMLLibXML.getNode(args.get(1).scalar()); + Templates templates = TransformerFactory.newInstance().newTemplates(new DOMSource(node)); + return blessedState(STATE_KEY, new StylesheetState(templates), + "XML::LibXSLT::Stylesheet").getList(); + } catch (Exception e) { + throw new RuntimeException("XML::LibXSLT stylesheet parse failed: " + e.getMessage(), e); + } + } + + public static RuntimeList parseStylesheetFile(RuntimeArray args, int ctx) { + try { + Templates templates = TransformerFactory.newInstance() + .newTemplates(new StreamSource(args.get(1).toString())); + return blessedState(STATE_KEY, new StylesheetState(templates), + "XML::LibXSLT::Stylesheet").getList(); + } catch (Exception e) { + throw new RuntimeException("XML::LibXSLT stylesheet parse failed: " + e.getMessage(), e); + } + } + + private static Transformer transformer(RuntimeArray args) throws Exception { + StylesheetState stylesheet = (StylesheetState) state(args.get(0).scalar(), STATE_KEY); + Transformer transformer = stylesheet.templates().newTransformer(); + for (int i = 2; i + 1 < args.size(); i += 2) { + String value = args.get(i + 1).toString(); + if (value.length() >= 2 && value.startsWith("'") && value.endsWith("'")) { + value = value.substring(1, value.length() - 1); + } + transformer.setParameter(args.get(i).toString(), value); + } + return transformer; + } + + public static RuntimeList transform(RuntimeArray args, int ctx) { + try { + Transformer transformer = transformer(args); + Node input = XMLLibXML.getNode(args.get(1).scalar()); + String method = transformer.getOutputProperty(OutputKeys.METHOD); + if ("text".equalsIgnoreCase(method)) { + StringWriter writer = new StringWriter(); + transformer.transform(new DOMSource(input), new StreamResult(writer)); + return blessedState(RESULT_KEY, new TextResult(writer.toString()), + "XML::LibXSLT::Result").getList(); + } + DOMResult result = new DOMResult(); + transformer.transform(new DOMSource(input), result); + Node output = result.getNode(); + if (output instanceof Document document) return XMLLibXML.wrapNode(document).getList(); + Document document = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument(); + document.appendChild(document.importNode(output, true)); + return XMLLibXML.wrapNode(document).getList(); + } catch (Exception e) { + throw new RuntimeException("XML::LibXSLT transform failed: " + e.getMessage(), e); + } + } + + public static RuntimeList outputString(RuntimeArray args, int ctx) { + try { + RuntimeScalar result = args.get(1).scalar(); + try { + TextResult text = (TextResult) state(result, RESULT_KEY); + return new RuntimeScalar(text.value()).getList(); + } catch (Exception ignored) { + StringWriter writer = new StringWriter(); + StylesheetState stylesheet = (StylesheetState) state(args.get(0).scalar(), STATE_KEY); + Transformer serializer = stylesheet.templates().newTransformer(); + serializer.transform(new DOMSource(XMLLibXML.getNode(result)), new StreamResult(writer)); + return new RuntimeScalar(writer.toString()).getList(); + } + } catch (Exception e) { + throw new RuntimeException("XML::LibXSLT output failed: " + e.getMessage(), e); + } + } + + private static String outputProperty(RuntimeScalar self, String name) { + try { + StylesheetState stylesheet = (StylesheetState) state(self, STATE_KEY); + String value = stylesheet.templates().getOutputProperties().getProperty(name); + return value == null ? "" : value; + } catch (Exception e) { + return ""; + } + } + + public static RuntimeList outputMethod(RuntimeArray args, int ctx) { + return new RuntimeScalar(outputProperty(args.get(0).scalar(), OutputKeys.METHOD)).getList(); + } + + public static RuntimeList outputEncoding(RuntimeArray args, int ctx) { + return new RuntimeScalar(outputProperty(args.get(0).scalar(), OutputKeys.ENCODING)).getList(); + } + + public static RuntimeList mediaType(RuntimeArray args, int ctx) { + return new RuntimeScalar(outputProperty(args.get(0).scalar(), OutputKeys.MEDIA_TYPE)).getList(); + } +} diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java b/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java index 468e5834d..f0ac24cf7 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java @@ -251,6 +251,10 @@ public static void callDestroy(RuntimeBase referent) { // %DEFERRED hash), causing infinite recursion in Moo/DBIx::Class. if (referent instanceof RuntimeCode code) { if (code.stashRefCount <= 0) { + if (ReachabilityWalker.strongCycleRetainsWeakReferent(code)) { + code.refCount = 1; + return; + } code.releaseCaptures(); } } diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java index f6adef722..ad9169df5 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java @@ -563,6 +563,13 @@ private static boolean enqueueStrongEdges(RuntimeBase cur, RuntimeBase target, if (enqueueStrongScalar(cap, target, seen, todo)) return true; } } + if (code.capturedAggregates != null) { + for (RuntimeBase cap : code.capturedAggregates) { + if (cap == null) continue; + if (cap == target) return true; + if (seen.add(cap)) todo.addLast(cap); + } + } if (cur instanceof org.perlonjava.backend.bytecode.InterpretedCode interpreted && interpreted.capturedVars != null) { for (RuntimeBase cap : interpreted.capturedVars) { @@ -595,6 +602,28 @@ private static boolean enqueueStrongEdges(RuntimeBase cur, RuntimeBase target, return false; } + /** + * True when a CODE cycle strongly retains an object that has an external + * weak reference. Perl refcounting keeps this graph alive; AnyEvent uses + * it for weak event-loop registries whose watcher callbacks own themselves + * through captured state. + */ + public static boolean strongCycleRetainsWeakReferent(RuntimeCode code) { + if (code == null || !hasStrongCycle(code)) return false; + final int MAX_VISITS = 50_000; + Set seen = Collections.newSetFromMap(new IdentityHashMap<>()); + java.util.ArrayDeque todo = new java.util.ArrayDeque<>(); + seen.add(code); + todo.addLast(code); + int visits = 0; + while (!todo.isEmpty() && visits++ < MAX_VISITS) { + RuntimeBase current = todo.removeFirst(); + if (WeakRefRegistry.hasWeakRefsTo(current)) return true; + enqueueStrongEdges(current, null, seen, todo); + } + return false; + } + private static boolean enqueueStrongScalar(RuntimeScalar s, RuntimeBase target, Set seen, java.util.ArrayDeque todo) { diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java index 12887a08c..e1485aff3 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGlob.java @@ -75,13 +75,7 @@ public static RuntimeArray localizedUnderscoreArrayForCurrentCall() { // See dev/modules/anon_sub_naming.md. public String nameOverride; - /** - * Tracks how many RuntimeScalar variables hold a GLOBREFERENCE to this glob. - * Used by scopeExitCleanup to avoid closing IO when other variables still - * reference the same glob. Starts at 0 (before any variable holds it). - * Incremented in RuntimeScalar.setLarge() when a GLOBREFERENCE is assigned, - * decremented in scopeExitCleanup(). IO is only closed when this reaches 0. - */ + /** Number of scalar wrappers currently pointing at this anonymous IO glob. */ public int ioHolderCount = 0; /** diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java index 5243544ab..a01a7da48 100644 --- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java +++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java @@ -203,17 +203,7 @@ private static boolean mightBeInteger(String s) { */ public boolean numericContextSeen; - /** - * True if this scalar was the direct target of an {@code open()} call that - * created a new anonymous filehandle glob. Used by {@link #scopeExitCleanup} - * to distinguish "owned" filehandles (should be closed at scope exit) from - * copies/aliases of shared handles (should NOT be closed, as other variables - * still reference the same glob). - *

- * Set by {@link org.perlonjava.runtime.operators.IOOperator#open} after creating - * a new anonymous glob. NOT copied by {@link #set(RuntimeScalar)}, so copies - * like {@code my $io = $handles->[$hid]} remain {@code false}. - */ + /** True on the scalar slot that owns a newly created anonymous IO glob. */ public boolean ioOwner; /** @@ -1703,10 +1693,8 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) { // See also: closeIOOnDrop() javadoc, dev/design/io_handle_lifecycle.md // ────────────────────────────────────────────────────────────────── - // Track ioHolderCount for anonymous glob IO lifecycle management. - // When a GLOBREFERENCE is copied to another variable, increment the glob's - // holder count so scopeExitCleanup won't close the IO prematurely. - // When a GLOBREFERENCE is overwritten, decrement the old glob's holder count. + // Track anonymous-glob aliases so the owning slot only releases the + // descriptor when no copied scalar still points at that glob. if (value.type == GLOBREFERENCE && value.value instanceof RuntimeGlob newGlob && newGlob.globName == null) { newGlob.ioHolderCount++; @@ -3377,34 +3365,7 @@ private void closeIOOnDrop() { } } - /** - * Called from JVM bytecode at scope exit to eagerly free fd numbers - * for anonymous lexical filehandles ({@code open(my $fh, ...)}). - *

- * This only unregisters the fileno (returning the fd number to the - * recycle pool) — it does NOT close the underlying IO stream. This is safe - * for shared handles: if another variable references the same RuntimeGlob, - * the IO stream stays open and functional. If the other reference calls - * {@code fileno()}, a new fd number is assigned via {@code assignFileno()}. - *

- * The actual IO close is still handled by PhantomReference-based GC in - * {@link RuntimeIO#processAbandonedGlobs()}, which only fires when the - * RuntimeGlob is truly unreachable (no variables reference it). - *

- * Why we don't close IO here: Without reference counting, there is - * no way to know if other variables still reference the same RuntimeGlob. - * Closing IO at scope exit broke Test2::Formatter::TAP and Capture::Tiny - * (see git history). Unregistering the fd is safe because: - *

    - *
  • The IO stream stays open for reading/writing
  • - *
  • Other references can still use the handle normally
  • - *
  • {@code fileno()} on other references will assign a fresh fd
  • - *
- * - * @param scalar the RuntimeScalar being cleaned up (may be null) - * @see RuntimeIO#registerGlobForFdRecycling - * @see RuntimeIO#processAbandonedGlobs() - */ + /** Performs scalar-specific scope cleanup, including owned anonymous IO fds. */ public static void scopeExitCleanup(RuntimeScalar scalar) { if (scalar == null) return; @@ -3433,7 +3394,6 @@ public static void scopeExitCleanup(RuntimeScalar scalar) { // When all three conditions are true, the entire method body is a no-op: // - refCountOwned=false → deferDecrementIfTracked returns immediately // - captureCount=0 → capture handling branch not taken - // - ioOwner=false → IO fd recycling branch not taken if (!scalar.refCountOwned && scalar.captureCount == 0 && !scalar.ioOwner && !scalar.ownsScalarReferenceContents && scalar.type != RuntimeScalarType.TIED_SCALAR) { @@ -3542,7 +3502,6 @@ public static void scopeExitCleanup(RuntimeScalar scalar) { return; } - // Existing: IO fd recycling for anonymous filehandle globs if (scalar.ioOwner && scalar.type == GLOBREFERENCE && scalar.value instanceof RuntimeGlob glob && glob.globName == null) { @@ -3553,9 +3512,6 @@ public static void scopeExitCleanup(RuntimeScalar scalar) { glob.ioHolderCount--; } if (glob.ioHolderCount <= 0) { - // Only unregister the fd number — do NOT close the IO stream. - // This frees the fd for reuse while keeping the IO functional - // for any other variables that reference the same glob. io.unregisterFileno(); } } diff --git a/src/main/perl/lib/CPAN/Distribution.pm b/src/main/perl/lib/CPAN/Distribution.pm index be631e4ed..5295a4a7c 100644 --- a/src/main/perl/lib/CPAN/Distribution.pm +++ b/src/main/perl/lib/CPAN/Distribution.pm @@ -3496,6 +3496,28 @@ sub _perlonjava_available_file_satisfies_prereq { return $self->_perlonjava_skip_dependency_tests ? 1 : 0; } +sub _perlonjava_expand_provider_requirements { + my ($merged, $prereq_pm) = @_; + require PerlOnJava::ProviderManifest; + + my %expanded; + my @queue = $merged->required_modules; + while (my $module = shift @queue) { + next if $expanded{$module}++; + my $provider = PerlOnJava::ProviderManifest->provider_for($module) + or next; + my $requires = $provider->{requires} || {}; + for my $required_module (sort keys %$requires) { + my $version = $requires->{$required_module}; + $merged->add_minimum($required_module, $version); + $prereq_pm->{requires}{$required_module} = $version + unless exists $prereq_pm->{requires}{$required_module}; + push @queue, $required_module; + } + } + return; +} + sub unsat_prereq { my($self,$slot) = @_; my($merged_hash,$prereq_pm) = $self->prereqs_for_slot($slot); @@ -3505,6 +3527,7 @@ sub unsat_prereq { return; } my $merged = CPAN::Meta::Requirements->from_string_hash($merged_hash); + _perlonjava_expand_provider_requirements($merged, $prereq_pm); my @merged = sort $merged->required_modules; CPAN->debug("all merged_prereqs[@merged]") if $CPAN::DEBUG; NEED: for my $need_module ( @merged ) { diff --git a/src/main/perl/lib/PerlOnJava/ProviderManifest.pm b/src/main/perl/lib/PerlOnJava/ProviderManifest.pm index e52b0b98e..8b6cbf3ae 100644 --- a/src/main/perl/lib/PerlOnJava/ProviderManifest.pm +++ b/src/main/perl/lib/PerlOnJava/ProviderManifest.pm @@ -51,6 +51,17 @@ sub manifest { unless $valid_provider{$entry->{provider}}; die "Invalid shadow policy $entry->{shadow_policy}\n" unless $valid_shadow{$entry->{shadow_policy}}; + if (exists $entry->{requires}) { + die "Invalid provider requirements for $entry->{module}\n" + unless ref($entry->{requires}) eq 'HASH'; + for my $module (keys %{ $entry->{requires} }) { + die "Invalid provider requirement module for $entry->{module}\n" + unless defined($module) && length($module); + my $version = $entry->{requires}{$module}; + die "Invalid provider requirement version for $entry->{module}\n" + if ref($version) || !defined($version); + } + } } return $manifest; } diff --git a/src/main/perl/lib/PerlOnJava/providers.json b/src/main/perl/lib/PerlOnJava/providers.json index 80a6e0430..40c2f64e0 100644 --- a/src/main/perl/lib/PerlOnJava/providers.json +++ b/src/main/perl/lib/PerlOnJava/providers.json @@ -47,7 +47,21 @@ "distribution": "XML-LibXML", "provider": "java-xs", "shadow_policy": "forbidden", - "test_strategy": "bundled-provider" + "test_strategy": "bundled-provider", + "requires": { + "XML::NamespaceSupport": "0" + } + }, + { + "module": "XML::LibXSLT", + "version": "2.003000", + "distribution": "XML-LibXSLT", + "provider": "java-xs", + "shadow_policy": "forbidden", + "test_strategy": "bundled-provider", + "requires": { + "XML::LibXML": "1.70" + } }, { "module": "Set::Object", diff --git a/src/main/perl/lib/XML/LibXSLT.pm b/src/main/perl/lib/XML/LibXSLT.pm new file mode 100644 index 000000000..e6f2ddf72 --- /dev/null +++ b/src/main/perl/lib/XML/LibXSLT.pm @@ -0,0 +1,56 @@ +package XML::LibXSLT; + +use strict; +use warnings; +use Carp (); +sub REQUIRE_XML_LIBXML_ABI_VERSION { 2 } +use XML::LibXML 1.70; +use XSLoader; + +our $VERSION = '2.003000'; +XSLoader::load('XML::LibXSLT', $VERSION); + +sub new { + my $class = shift; + return bless { @_ }, $class; +} + +sub parse_stylesheet { + my ($self, $document) = @_; + return $self->_parse_stylesheet($document); +} + +sub parse_stylesheet_file { + my ($self, $filename) = @_; + return $self->_parse_stylesheet_file($filename); +} + +package XML::LibXSLT::Stylesheet; + +sub output_as_chars { shift->_output_string($_[0], 2) } +sub output_as_bytes { shift->_output_string($_[0], 1) } +sub output_string { shift->_output_string($_[0], 0) } +sub transform_into_chars { + my $self = shift; + return $self->output_as_chars($self->transform(@_)); +} + +1; + +__END__ + +=head1 NAME + +XML::LibXSLT - XSLT transformations backed by the JDK JAXP provider + +=head1 DESCRIPTION + +This PerlOnJava port implements the core XML::LibXSLT stylesheet parsing, +transformation, parameter, output, and metadata APIs without native libxslt. + +=head1 COPYRIGHT AND LICENSE + +The XML::LibXSLT interface is copyright 2001-2009 AxKit.com Ltd. This port is +free software; you may redistribute it under the same terms as Perl itself. + +=cut diff --git a/src/test/resources/module/XML-LibXSLT/t/basic.t b/src/test/resources/module/XML-LibXSLT/t/basic.t new file mode 100644 index 000000000..a1a6ec492 --- /dev/null +++ b/src/test/resources/module/XML-LibXSLT/t/basic.t @@ -0,0 +1,27 @@ +use strict; +use warnings; +use Test::More; +use XML::LibXML; +use XML::LibXSLT; + +my $style = XML::LibXML->load_xml(string => <<'XSL'); + + + + + +XSL +my $document = XML::LibXML->load_xml(string => 'world'); +my $stylesheet = XML::LibXSLT->new->parse_stylesheet($style); + +ok($stylesheet->can('transform'), 'parsed stylesheet exposes the transformation API'); +is($stylesheet->output_method, 'text', 'reads the declared output method'); +is( + $stylesheet->output_as_chars( + $stylesheet->transform($document, greeting => q{'hello '}), + ), + 'hello world', + 'transforms XML and passes quoted XPath string parameters', +); + +done_testing; diff --git a/src/test/resources/unit/cpan_bundled_providers.t b/src/test/resources/unit/cpan_bundled_providers.t index eac04e4ab..455323376 100644 --- a/src/test/resources/unit/cpan_bundled_providers.t +++ b/src/test/resources/unit/cpan_bundled_providers.t @@ -14,7 +14,7 @@ if ($is_perlonjava) { ok(PerlOnJava::ProviderManifest->can('provider_for'), 'loaded provider manifest'); my @providers = PerlOnJava::ProviderManifest->providers; -is(scalar(@providers), 8, 'initial bundled-provider manifest has eight module entries'); +is(scalar(@providers), 9, 'bundled-provider manifest has nine module entries'); my %expected = ( DBI => [ '1.643', 'bundled-perl' ], @@ -23,6 +23,7 @@ my %expected = ( 'HTML::Parser' => [ '3.83', 'java-xs' ], 'HTML::Entities' => [ '3.83', 'java-xs' ], 'XML::LibXML' => [ '2.0210', 'java-xs' ], + 'XML::LibXSLT' => [ '2.003000', 'java-xs' ], 'Set::Object' => [ '1.43', 'compatibility-shim' ], 'Package::Stash::XS' => [ '0.30', 'compatibility-shim' ], ); @@ -35,8 +36,14 @@ for my $module (sort keys %expected) { is($provider->{shadow_policy}, 'forbidden', "$module cannot be shadowed"); } +is_deeply( + PerlOnJava::ProviderManifest->provider_for('XML::LibXML')->{requires}, + { 'XML::NamespaceSupport' => '0' }, + 'bundled XML provider declares its additional runtime dependency', +); + SKIP: { - skip 'CPAN integration and provider runtime smoke are PerlOnJava-specific', 27 + skip 'CPAN integration and provider runtime smoke are PerlOnJava-specific', 32 unless $is_perlonjava; require CPAN::Module; @@ -85,6 +92,18 @@ SKIP: { like($message, qr/version 1\.643.*does not satisfy.*shadowing.*forbidden/s, 'incompatible-provider failure explains the policy'); + $requirements = CPAN::Meta::Requirements->new; + $requirements->add_minimum('XML::LibXML', '2.0000'); + my $prereq_pm = { requires => { 'XML::LibXML' => '2.0000' } }; + CPAN::Distribution::_perlonjava_expand_provider_requirements( + $requirements, $prereq_pm); + ok($requirements->accepts_module('XML::NamespaceSupport', '0.12'), + 'resolver expands a bundled provider runtime dependency'); + ok(exists $prereq_pm->{requires}{'XML::NamespaceSupport'}, + 'expanded provider dependency is classified as a runtime requirement'); + is($prereq_pm->{requires}{'XML::NamespaceSupport'}, '0', + 'expanded provider dependency retains its minimum version'); + SKIP: { skip 'full provider loading is covered by the JVM smoke gate', 16 if $ENV{JPERL_INTERPRETER}; diff --git a/src/test/resources/unit/socket_nonblocking_getpeername.t b/src/test/resources/unit/socket_nonblocking_getpeername.t new file mode 100644 index 000000000..5f8a35438 --- /dev/null +++ b/src/test/resources/unit/socket_nonblocking_getpeername.t @@ -0,0 +1,39 @@ +use strict; +use warnings; +use Test::More; +use Socket qw(AF_INET SOCK_STREAM SOL_SOCKET SO_REUSEADDR inet_aton + pack_sockaddr_in unpack_sockaddr_in); +use Fcntl qw(F_GETFL F_SETFL O_NONBLOCK); + +socket(my $server, AF_INET, SOCK_STREAM, 0) or die "server socket: $!"; +setsockopt($server, SOL_SOCKET, SO_REUSEADDR, 1) or die "reuseaddr: $!"; +bind($server, pack_sockaddr_in(0, inet_aton('127.0.0.1'))) or die "bind: $!"; +listen($server, 4) or die "listen: $!"; +my ($port) = unpack_sockaddr_in(getsockname($server)); + +my %state; +my $created_fd = do { + socket($state{client}, AF_INET, SOCK_STREAM, 0) or die "client socket: $!"; + fileno($state{client}); +}; +is(fileno($state{client}), $created_fd, 'container-owned socket keeps its descriptor after creator scope exits'); +my $flags = fcntl($state{client}, F_GETFL, 0); +fcntl($state{client}, F_SETFL, $flags | O_NONBLOCK) or die "nonblocking: $!"; +connect($state{client}, pack_sockaddr_in($port, inet_aton('127.0.0.1'))); + +my $write = ''; +vec($write, fileno($state{client}), 1) = 1; +ok(select(undef, $write, undef, 5) > 0, 'nonblocking connection becomes write-ready'); +ok(defined getpeername($state{client}), 'getpeername confirms a write-ready nonblocking connection'); + +my $accepted; +ok(accept($accepted, $server), 'accepts the pending client'); +$flags = fcntl($server, F_GETFL, 0); +fcntl($server, F_SETFL, $flags | O_NONBLOCK) or die "server nonblocking: $!"; +my $none = accept(my $extra, $server); +ok(!defined($none), 'a second accept on a nonblocking listener returns immediately'); + +close $accepted; +close $state{client}; +close $server; +done_testing; diff --git a/src/test/resources/unit/weak_blessed_watcher_cycle.t b/src/test/resources/unit/weak_blessed_watcher_cycle.t new file mode 100644 index 000000000..cc9a62cf5 --- /dev/null +++ b/src/test/resources/unit/weak_blessed_watcher_cycle.t @@ -0,0 +1,28 @@ +use strict; +use warnings; +use Scalar::Util qw(weaken); +use Test::More; + +our @registry; +our $destroyed = 0; + +{ + package Local::Watcher; + sub DESTROY { $main::destroyed++ } +} + +sub install_watcher { + my %state; + my $watcher = bless [ sub { return $state{watcher} } ], 'Local::Watcher'; + push @registry, $watcher; + weaken $registry[-1]; + + $state{watcher} = $watcher; +} + +install_watcher(); + +ok(defined $registry[0], 'weak registry entry survives through a captured hash cycle'); +is($destroyed, 0, 'captured hash retains a blessed watcher with DESTROY'); + +done_testing; diff --git a/src/test/resources/unit/xml_libxslt.t b/src/test/resources/unit/xml_libxslt.t new file mode 100644 index 000000000..92cc396ef --- /dev/null +++ b/src/test/resources/unit/xml_libxslt.t @@ -0,0 +1,21 @@ +use strict; +use warnings; +use Test::More; +use XML::LibXML; +use XML::LibXSLT; + +my $style = XML::LibXML->load_xml(string => <<'XSL'); + + + + + +XSL +my $input = XML::LibXML->load_xml(string => 'PerlOnJava'); +my $sheet = XML::LibXSLT->new->parse_stylesheet($style); +ok($sheet->can('transform'), 'parsed stylesheet exposes the transformation API'); +is($sheet->output_method, 'text', 'reports the stylesheet output method'); +my $result = $sheet->transform($input, prefix => q{'Hello '}); +is($sheet->output_as_chars($result), 'Hello PerlOnJava', 'transforms DOM input with a parameter'); + +done_testing;