Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
dadee8c
WIP: POE support - initial analysis and fix plan
fglock Apr 4, 2026
8c138d5
Fix exists(&sub) constant folding, add POSIX/Socket constants for POE
fglock Apr 4, 2026
15225a7
Fix indirect object syntax with variable class + parenthesized args
fglock Apr 4, 2026
84c58fa
Update POE plan: 35/53 test files passing
fglock Apr 4, 2026
4bb5f3f
Update POE plan: add Phase 3-5 roadmap and event loop test results
fglock Apr 4, 2026
c9f2295
Pre-populate %SIG with OS signal names like Perl does
fglock Apr 4, 2026
aacf5b3
Implement DESTROY support for blessed objects using java.lang.ref.Cle…
fglock Apr 4, 2026
1f44572
Fix foreach to see array modifications during iteration
fglock Apr 4, 2026
1b5e59f
Update POE plan: ses_session.t 35/41, document foreach-push and DESTR…
fglock Apr 4, 2026
318267f
Fix require expression parsing, non-blocking I/O, and 4-arg select
fglock Apr 4, 2026
eb009a0
Fix DestroyManager crash with overloaded classes (negative blessIds)
fglock Apr 4, 2026
777b694
Remove DestroyManager (Cleaner/proxy DESTROY) — proxy reconstruction …
fglock Apr 4, 2026
bbdbc78
Update poe.md: document DestroyManager removal, add Bugs 11-13
fglock Apr 4, 2026
1fb2f38
Fix 4-arg select() to properly poll pipe readiness instead of marking…
fglock Apr 4, 2026
5b3da3f
Update poe.md: add Bug 14 (select polling fix), clarify DESTROY limit…
fglock Apr 4, 2026
e66f097
Fix pipe fd registry mismatch and platform EAGAIN value
fglock Apr 4, 2026
7f66592
Update poe.md: document Bugs 15-17, Phase 3.4 signal pipe and postbac…
fglock Apr 4, 2026
fd975dc
Fix select() bitvector write-back and fd allocation collision
fglock Apr 5, 2026
772b789
Update poe.md: document Bugs 18-20, Phase 3.5 select/fd fixes
fglock Apr 5, 2026
8470fdc
Update poe.md: comprehensive Phase 4 test inventory and plan
fglock Apr 5, 2026
bc6d389
Add POSIX terminal/stat constants, sysconf, setsid for POE::Wheel::Ru…
fglock Apr 5, 2026
b139075
Update poe.md: Phase 4.1/4.2 complete, document I/O hang pattern and …
fglock Apr 5, 2026
da14035
Fix fileno() returning undef for regular file handles
fglock Apr 5, 2026
cecb354
Implement sysseek operator for JVM and interpreter backends
fglock Apr 5, 2026
5cccc73
Update poe.md: Phase 4.3 analysis complete, DESTROY is root cause of …
fglock Apr 5, 2026
33cfa4a
Add Phase 4.7 Windows platform support plan to poe.md
fglock Apr 5, 2026
5eb4af3
Add Windows platform support for errno, signals, and socket constants
fglock Apr 5, 2026
1651b95
Fix non-blocking pipe I/O and EBADF errno handling
fglock Apr 5, 2026
575fff5
Add Phase 4.8 plan: fix filehandle dup (open FH, ">&OTHER")
fglock Apr 5, 2026
c11283e
Implement refcounted filehandle duplication (DupIOHandle)
fglock Apr 5, 2026
9be9c99
Update POE plan: Phase 4.8 complete, updated test results and next steps
fglock Apr 5, 2026
082ceee
Fix regressions: goto in map/grep hang, parsimonious dup closes, glob…
fglock Apr 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 59 additions & 2 deletions dev/design/object_lifecycle.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,69 @@
# Object Lifecycle: DESTROY and Weak References

**Status**: Design Proposal (Technically Reviewed)
**Version**: 1.0
**Version**: 1.1
**Created**: 2026-03-26
**Updated**: 2026-04-04
**Supersedes**: destroy_support.md, weak_references.md, auto_close.md
**Related**: moo_support.md (Phases 30-31)

## Overview
## Current State (v1.1 — 2026-04-04)

### Cleaner/Proxy DESTROY Removed

An initial implementation using `java.lang.ref.Cleaner` + proxy object reconstruction
was attempted and removed. The approach:

1. At `bless()` time, registered objects with a `Cleaner` to detect GC
2. Captured internal data (hash elements, array elements) separately from the object
3. When the Cleaner fired, enqueued a `DestroyTask` with the captured data
4. At safe points, reconstructed a proxy object and called DESTROY on it

**Why it was removed — the proxy reconstruction is fundamentally fragile:**

- **`close()` corruption**: Calling `close($self->{_fh})` inside DESTROY on a
proxy hash corrupts subsequent hash access (`$self->{_filename}` fails with
"Not a HASH reference"). The exact mechanism is unclear but reproducible.
- **Overloaded class ID collision**: Classes with overloading get negative blessIds.
The original code used `Math.abs(blessId)` as cache keys, colliding with normal
class IDs (fixed before removal, but illustrates the fragility).
- **Incomplete reconstruction**: The proxy can't fully replicate the original object's
behavior — tied variables, magic, overloaded operators, etc. may all behave
differently on a reconstructed wrapper vs. the original.

**The fundamental Cleaner limitation**: The cleaning action **must not** hold a
reference to the tracked object (or it's never GC'd and the Cleaner never fires).
This forces proxy reconstruction, which is inherently lossy.

### What Still Works

- **Tied variable DESTROY**: Works via `TieScalar.tiedDestroy()` / `tieCallIfExists("DESTROY")`.
These use a different mechanism (scope-based cleanup) and are unaffected.
- **`weaken()` / `isweak()`**: Stubs (no-op / always false). JVM's tracing GC handles
circular references natively, so the primary use case (breaking cycles) is unnecessary.

### Impact on POE

POE uses `POE::Session::AnonEvent::DESTROY` to decrement session reference counts
when postback/callback coderefs go out of scope. Without DESTROY:
- POE's core event loop (yield, delay, signals, timers, I/O) works correctly
- Sessions that use postbacks won't get automatic cleanup
- The event loop may not exit naturally (session refcount never reaches 0)
- **Workaround**: Explicit `$postback = undef` or patching POE to use explicit
session management instead of relying on DESTROY timing

### Future Directions

If DESTROY support is revisited, the recommended approach is **scope-based cleanup**
rather than GC-based proxy reconstruction:

1. **Reference counting for blessed objects only** — track refcount at `bless()` time,
decrement on reassignment/undef, call DESTROY when count reaches 0
2. **`Local.localTeardown()`** — deterministic cleanup at scope exit for lexical variables
3. **`DeferBlock` integration** — leverage existing scope-exit callback infrastructure

The GC-based Cleaner approach should only be used as a safety net for escaped
references, not as the primary DESTROY mechanism.

This document covers Perl's object lifecycle management in PerlOnJava:
1. **DESTROY** - Destructor methods called when objects become unreachable
Expand Down
562 changes: 562 additions & 0 deletions dev/modules/poe.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ static void visitBinaryOperator(BytecodeCompiler bytecodeCompiler, BinaryOperato

// Handle I/O and misc binary operators that use MiscOpcodeHandler (filehandle + args → list)
switch (node.operator) {
case "binmode", "seek", "eof", "close", "fileno", "getc", "printf":
case "binmode", "seek", "sysseek", "eof", "close", "fileno", "getc", "printf":
compileBinaryAsListOp(bytecodeCompiler, node);
return;
case "tell":
Expand Down Expand Up @@ -681,6 +681,7 @@ private static void compileBinaryAsListOp(BytecodeCompiler bytecodeCompiler, Bin
int opcode = switch (node.operator) {
case "binmode" -> Opcodes.BINMODE;
case "seek" -> Opcodes.SEEK;
case "sysseek" -> Opcodes.SYSSEEK;
case "eof" -> Opcodes.EOF_OP;
case "close" -> Opcodes.CLOSE;
case "fileno" -> Opcodes.FILENO;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public static void emitBinaryOperatorNode(EmitterVisitor emitterVisitor, BinaryO
case "close", "readline", "fileno", "getc", "tell" ->
EmitOperator.handleReadlineOperator(emitterVisitor, node);

case "binmode", "seek" -> EmitOperator.handleBinmodeOperator(emitterVisitor, node);
case "binmode", "seek", "sysseek" -> EmitOperator.handleBinmodeOperator(emitterVisitor, node);

// String operations
case "join", "sprintf" -> EmitOperator.handleSubstr(emitterVisitor, node);
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/org/perlonjava/core/Configuration.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@ public final class Configuration {
* Automatically populated by Gradle/Maven during build.
* DO NOT EDIT MANUALLY - this value is replaced at build time.
*/
public static final String gitCommitId = "a7261e446";
public static final String gitCommitId = "69c74c914";

/**
* Git commit date of the build (ISO format: YYYY-MM-DD).
* Automatically populated by Gradle/Maven during build.
* DO NOT EDIT MANUALLY - this value is replaced at build time.
*/
public static final String gitCommitDate = "2026-04-04";
public static final String gitCommitDate = "2026-04-05";

// Prevent instantiation
private Configuration() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ public static Node parseCoreOperator(Parser parser, LexerToken token, int startI
case "system", "exec" -> OperatorParser.parseSystem(parser, token, currentIndex);
case "readline", "eof", "tell" -> OperatorParser.parseReadline(parser, token, currentIndex);
case "binmode" -> OperatorParser.parseBinmodeOperator(parser, token, currentIndex);
case "seek" -> OperatorParser.parseSeek(parser, token, currentIndex);
case "seek", "sysseek" -> OperatorParser.parseSeek(parser, token, currentIndex);
case "printf", "print", "say" -> OperatorParser.parsePrint(parser, token, currentIndex);
case "delete", "exists" -> OperatorParser.parseDelete(parser, token, currentIndex);
case "defined" -> OperatorParser.parseDefined(parser, token, currentIndex);
Expand Down
30 changes: 24 additions & 6 deletions src/main/java/org/perlonjava/frontend/parser/OperatorParser.java
Original file line number Diff line number Diff line change
Expand Up @@ -1120,19 +1120,37 @@ static OperatorNode parseRequire(Parser parser) {
// This avoids treating module names like "Encode" as subroutine calls when a sub
// with the same name exists in the current package (e.g., sub Encode in Image::ExifTool)
// But don't intercept quote-like operators like q(), qq(), etc.
//
// However, if the bareword is followed by `->`, it's a method call expression
// (e.g., `require File::Spec->catfile(...)`) and should be parsed as an expression.
int savedIndex = parser.tokenIndex;
String moduleName = IdentifierParser.parseSubroutineIdentifier(parser);
if (CompilerOptions.DEBUG_ENABLED) parser.ctx.logDebug("require module name `" + moduleName + "`");
if (moduleName == null) {
throw new PerlCompilerException(parser.tokenIndex, "Syntax error", parser.ctx.errorUtil);
}

// Check if module name starts with ::
if (moduleName.startsWith("::")) {
throw new PerlCompilerException(parser.tokenIndex, "Bareword in require must not start with a double-colon: \"" + moduleName + "\"", parser.ctx.errorUtil);
}
// Check if followed by `->` — if so, this is a method call, not a module name
LexerToken nextToken = peek(parser);
if (nextToken.type == OPERATOR && nextToken.text.equals("->")) {
// Restore position and fall through to expression parsing
parser.tokenIndex = savedIndex;
ListNode op = ListParser.parseZeroOrOneList(parser, 0);
if (op.elements.isEmpty()) {
op.elements.add(scalarUnderscore(parser));
operand = op;
} else {
operand = op;
}
} else {
// Check if module name starts with ::
if (moduleName.startsWith("::")) {
throw new PerlCompilerException(parser.tokenIndex, "Bareword in require must not start with a double-colon: \"" + moduleName + "\"", parser.ctx.errorUtil);
}

String fileName = NameNormalizer.moduleToFilename(moduleName);
operand = ListNode.makeList(new StringNode(fileName, parser.tokenIndex));
String fileName = NameNormalizer.moduleToFilename(moduleName);
operand = ListNode.makeList(new StringNode(fileName, parser.tokenIndex));
}
} else {
// Check for the specific pattern: :: followed by identifier (which is invalid for require)
if (token.type == OPERATOR && token.text.equals("::")) {
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/org/perlonjava/frontend/parser/Parser.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public class Parser {
// Are we currently parsing a my/our/state declaration's variable list?
// Used to suppress strict vars checking for the variable being declared.
public boolean parsingDeclaration = false;
// Are we parsing a variable used as the class in indirect object syntax?
// Suppresses the "syntax error" check for $var( in Variable.java
public boolean parsingIndirectObject = false;
// Are we parsing the top level script?
public boolean isTopLevelScript = false;
// Are we parsing inside a class block?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,11 @@ static Node parseSubroutineCall(Parser parser, boolean isMethod) {
if (!subExists && peek(parser).text.equals("$") && isValidIndirectMethod(subName) && !prototypeHasGlob) {
int currentIndex2 = parser.tokenIndex;
// Parse the variable that holds the class name
// Set flag to allow $var( pattern (normally a syntax error)
boolean savedIndirectObj = parser.parsingIndirectObject;
parser.parsingIndirectObject = true;
Node classVar = ParsePrimary.parsePrimary(parser);
parser.parsingIndirectObject = savedIndirectObj;
if (classVar != null) {
LexerToken nextTok = peek(parser);
// Check this isn't actually a binary operator like $type + 1
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/org/perlonjava/frontend/parser/Variable.java
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public static Node parseVariable(Parser parser, String sigil) {

// Variable name is valid.
// Check for illegal characters after a variable
if (!parser.parsingForLoopVariable && peek(parser).text.equals("(") && !sigil.equals("&")) {
if (!parser.parsingForLoopVariable && !parser.parsingIndirectObject && peek(parser).text.equals("(") && !sigil.equals("&")) {
// Parentheses are only allowed after a variable in specific cases:
// - `for my $v (...`
// - `&name(...`
Expand Down
200 changes: 200 additions & 0 deletions src/main/java/org/perlonjava/runtime/io/BorrowedIOHandle.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package org.perlonjava.runtime.io;

import org.perlonjava.runtime.runtimetypes.RuntimeScalar;

import java.nio.charset.Charset;

import static org.perlonjava.runtime.runtimetypes.RuntimeIO.handleIOException;
import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.scalarTrue;

/**
* A non-owning IOHandle wrapper for Perl's parsimonious dup semantics ({@code >&=} / {@code <&=}).
*
* <h3>Background: parsimonious dup in Perl</h3>
* <p>When Perl executes {@code open(F, ">&=STDOUT")}, it performs an {@code fdopen()} —
* creating a new FILE* that shares the same fd as STDOUT. The key semantic difference
* from a full dup ({@code >&}) is:</p>
* <ul>
* <li>Both handles share the <em>same</em> file descriptor (same fileno).</li>
* <li>Closing the new handle ({@code close F}) does <em>not</em> close the underlying
* resource — the original handle (STDOUT) remains fully operational.</li>
* <li>This is a lightweight alias — no new OS-level file descriptor is allocated.</li>
* </ul>
*
* <h3>Implementation</h3>
* <p>BorrowedIOHandle delegates all I/O operations to the underlying delegate IOHandle,
* but overrides {@link #close()} to only flush — never closing the delegate. This
* ensures that after {@code close F}, the original handle (e.g. STDOUT) keeps working.</p>
*
* <p>Unlike {@link DupIOHandle}, this wrapper:</p>
* <ul>
* <li>Does NOT allocate a new fd number (shares the delegate's fileno)</li>
* <li>Does NOT use reference counting (the delegate is never closed by us)</li>
* <li>Is much simpler — just a thin delegation layer with a close-guard</li>
* </ul>
*
* @see DupIOHandle for full dup semantics ({@code >&}) with reference counting
* @see IOOperator#openFileHandleDup(String, String) where this is created
*/
public class BorrowedIOHandle implements IOHandle {

/** The underlying handle we're borrowing — never closed by us. */
private final IOHandle delegate;
/** Per-instance closed flag. Once true, all I/O operations on THIS wrapper fail. */
private boolean closed = false;

/**
* Creates a BorrowedIOHandle wrapping the given delegate.
*
* @param delegate the underlying IOHandle to borrow (not owned — will not be closed)
*/
public BorrowedIOHandle(IOHandle delegate) {
this.delegate = delegate;
}

/**
* Returns the underlying delegate IOHandle.
*/
public IOHandle getDelegate() {
return delegate;
}

// ---- Delegated I/O operations (check closed state first) ----

@Override
public RuntimeScalar write(String string) {
if (closed) return handleClosed("write");
return delegate.write(string);
}

@Override
public RuntimeScalar flush() {
if (closed) return scalarTrue;
return delegate.flush();
}

@Override
public RuntimeScalar sync() {
if (closed) return scalarTrue;
return delegate.sync();
}

@Override
public RuntimeScalar doRead(int maxBytes, Charset charset) {
if (closed) return handleClosed("read");
return delegate.doRead(maxBytes, charset);
}

@Override
public RuntimeScalar fileno() {
if (closed) return handleClosed("fileno");
// Return the delegate's fileno — parsimonious dup shares the same fd
return delegate.fileno();
}

@Override
public RuntimeScalar eof() {
if (closed) return scalarTrue;
return delegate.eof();
}

@Override
public RuntimeScalar tell() {
if (closed) return handleClosed("tell");
return delegate.tell();
}

@Override
public RuntimeScalar seek(long pos, int whence) {
if (closed) return handleClosed("seek");
return delegate.seek(pos, whence);
}

@Override
public RuntimeScalar truncate(long length) {
if (closed) return handleClosed("truncate");
return delegate.truncate(length);
}

@Override
public RuntimeScalar flock(int operation) {
if (closed) return handleClosed("flock");
return delegate.flock(operation);
}

@Override
public RuntimeScalar bind(String address, int port) {
if (closed) return handleClosed("bind");
return delegate.bind(address, port);
}

@Override
public RuntimeScalar connect(String address, int port) {
if (closed) return handleClosed("connect");
return delegate.connect(address, port);
}

@Override
public RuntimeScalar listen(int backlog) {
if (closed) return handleClosed("listen");
return delegate.listen(backlog);
}

@Override
public RuntimeScalar accept() {
if (closed) return handleClosed("accept");
return delegate.accept();
}

@Override
public boolean isBlocking() {
if (closed) return true;
return delegate.isBlocking();
}

@Override
public boolean setBlocking(boolean blocking) {
if (closed) return blocking;
return delegate.setBlocking(blocking);
}

@Override
public RuntimeScalar sysread(int length) {
if (closed) return handleClosed("sysread");
return delegate.sysread(length);
}

@Override
public RuntimeScalar syswrite(String data) {
if (closed) return handleClosed("syswrite");
return delegate.syswrite(data);
}

// ---- Close: flush only, do NOT close the delegate ----

/**
* Closes this borrowed handle.
*
* <p>Only flushes the delegate — does NOT close the underlying resource.
* This matches Perl's fdopen semantics where closing an fdopen'd FILE*
* does not invalidate the original handle.</p>
*/
@Override
public RuntimeScalar close() {
if (closed) {
return handleIOException(
new java.io.IOException("Handle is already closed."),
"Handle is already closed.");
}
closed = true;
// Only flush — never close the delegate. The original handle still owns it.
delegate.flush();
return scalarTrue;
}

private RuntimeScalar handleClosed(String operation) {
return handleIOException(
new java.io.IOException("Cannot " + operation + " on a closed handle."),
operation + " on closed handle failed");
}
}
Loading
Loading