Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c61b1c8
WIP: POE support - initial analysis and fix plan
fglock Apr 4, 2026
076a186
Fix exists(&sub) constant folding, add POSIX/Socket constants for POE
fglock Apr 4, 2026
6596617
Fix indirect object syntax with variable class + parenthesized args
fglock Apr 4, 2026
283e089
Update POE plan: 35/53 test files passing
fglock Apr 4, 2026
4e09c66
Update POE plan: add Phase 3-5 roadmap and event loop test results
fglock Apr 4, 2026
a83a038
Pre-populate %SIG with OS signal names like Perl does
fglock Apr 4, 2026
c01c8d6
Implement DESTROY support for blessed objects using java.lang.ref.Cle…
fglock Apr 4, 2026
089080b
Fix foreach to see array modifications during iteration
fglock Apr 4, 2026
db536eb
Update POE plan: ses_session.t 35/41, document foreach-push and DESTR…
fglock Apr 4, 2026
6bb01a1
Fix require expression parsing, non-blocking I/O, and 4-arg select
fglock Apr 4, 2026
1b1b4c2
Fix DestroyManager crash with overloaded classes (negative blessIds)
fglock Apr 4, 2026
deef73f
Remove DestroyManager (Cleaner/proxy DESTROY) — proxy reconstruction …
fglock Apr 4, 2026
6993e80
Update poe.md: document DestroyManager removal, add Bugs 11-13
fglock Apr 4, 2026
333d39e
Fix 4-arg select() to properly poll pipe readiness instead of marking…
fglock Apr 4, 2026
ad4db36
Update poe.md: add Bug 14 (select polling fix), clarify DESTROY limit…
fglock Apr 4, 2026
eff2f35
Fix pipe fd registry mismatch and platform EAGAIN value
fglock Apr 4, 2026
74fd127
Update poe.md: document Bugs 15-17, Phase 3.4 signal pipe and postbac…
fglock Apr 4, 2026
b995a5f
Fix select() bitvector write-back and fd allocation collision
fglock Apr 5, 2026
ad136f6
Update poe.md: document Bugs 18-20, Phase 3.5 select/fd fixes
fglock Apr 5, 2026
a15fbce
Update poe.md: comprehensive Phase 4 test inventory and plan
fglock Apr 5, 2026
3493423
Add POSIX terminal/stat constants, sysconf, setsid for POE::Wheel::Ru…
fglock Apr 5, 2026
864327d
Update poe.md: Phase 4.1/4.2 complete, document I/O hang pattern and …
fglock Apr 5, 2026
14ea123
Fix fileno() returning undef for regular file handles
fglock Apr 5, 2026
5b0ca13
Implement sysseek operator for JVM and interpreter backends
fglock Apr 5, 2026
3caa7cd
Update poe.md: Phase 4.3 analysis complete, DESTROY is root cause of …
fglock Apr 5, 2026
baf0e1d
Add Phase 4.7 Windows platform support plan to poe.md
fglock Apr 5, 2026
e27ba3d
Add Windows platform support for errno, signals, and socket constants
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 = "4aafb6057";
public static final String gitCommitId = "baf0e1df2";

/**
* 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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.perlonjava.runtime.io;

import org.perlonjava.runtime.runtimetypes.ErrnoVariable;
import org.perlonjava.runtime.runtimetypes.RuntimeScalar;
import org.perlonjava.runtime.runtimetypes.RuntimeScalarCache;

Expand Down Expand Up @@ -437,7 +438,7 @@ public RuntimeScalar flock(int operation) {
currentLock = fileChannel.tryLock(0, Long.MAX_VALUE, isShared);
if (currentLock == null) {
// Would block - return false
getGlobalVariable("main::!").set(11); // EAGAIN/EWOULDBLOCK
getGlobalVariable("main::!").set(ErrnoVariable.EAGAIN()); // EAGAIN/EWOULDBLOCK
return RuntimeScalarCache.scalarFalse;
}
} else {
Expand All @@ -453,7 +454,7 @@ public RuntimeScalar flock(int operation) {

} catch (OverlappingFileLockException e) {
// This happens when trying to lock a region already locked by this JVM
getGlobalVariable("main::!").set(11); // EAGAIN
getGlobalVariable("main::!").set(ErrnoVariable.EAGAIN()); // EAGAIN
return RuntimeScalarCache.scalarFalse;
} catch (IOException e) {
return handleIOException(e, "flock failed");
Expand Down
121 changes: 121 additions & 0 deletions src/main/java/org/perlonjava/runtime/io/FileDescriptorTable.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package org.perlonjava.runtime.io;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

import org.perlonjava.runtime.runtimetypes.RuntimeIO;

/**
* Maps simulated file descriptor numbers to IOHandle objects.
*
* <p>Java doesn't expose real POSIX file descriptors. This table assigns
* sequential integers starting from 3 (0, 1, 2 are reserved for
* stdin, stdout, stderr) and allows lookup by FD number.
*
* <p>Used by:
* <ul>
* <li>{@code fileno()} — to return a consistent FD for each handle</li>
* <li>4-arg {@code select()} — to map bit-vector bits back to handles</li>
* </ul>
*
* <p>Thread-safe: uses ConcurrentHashMap and AtomicInteger.
*/
public class FileDescriptorTable {

// Next FD number to assign. 0–2 are stdin/stdout/stderr.
private static final AtomicInteger nextFd = new AtomicInteger(3);

// FD number → IOHandle (for select() lookup)
private static final ConcurrentHashMap<Integer, IOHandle> fdToHandle = new ConcurrentHashMap<>();

// IOHandle identity → FD number (to avoid assigning multiple FDs to the same handle)
private static final ConcurrentHashMap<Integer, Integer> handleToFd = new ConcurrentHashMap<>();

/**
* Register an IOHandle and return its FD number.
* If the handle was already registered, returns the existing FD.
*
* @param handle the IOHandle to register
* @return the file descriptor number
*/
public static int register(IOHandle handle) {
int identity = System.identityHashCode(handle);
Integer existing = handleToFd.get(identity);
if (existing != null) {
return existing;
}
int fd = nextFd.getAndIncrement();
fdToHandle.put(fd, handle);
handleToFd.put(identity, fd);
// Keep RuntimeIO in sync to prevent fd collisions with socket()/socketpair()
RuntimeIO.advanceFilenoCounterPast(fd);
return fd;
}

/**
* Advances the nextFd counter past the given fd value.
* Called by RuntimeIO.assignFileno() to keep the two fd allocation
* systems in sync and prevent fd collisions.
*
* @param fd the fd value to advance past
*/
public static void advancePast(int fd) {
nextFd.updateAndGet(current -> Math.max(current, fd + 1));
}

/**
* Look up an IOHandle by its FD number.
*
* @param fd the file descriptor number
* @return the IOHandle, or null if not found
*/
public static IOHandle getHandle(int fd) {
return fdToHandle.get(fd);
}

/**
* Remove a handle from the table (e.g., on close).
*
* @param fd the file descriptor number to remove
*/
public static void unregister(int fd) {
IOHandle handle = fdToHandle.remove(fd);
if (handle != null) {
handleToFd.remove(System.identityHashCode(handle));
}
}

/**
* Check if a read-end handle has data available without blocking.
* Returns true if the handle is "ready for reading".
*
* @param handle the IOHandle to check
* @return true if data is available or handle is at EOF/closed
*/
public static boolean isReadReady(IOHandle handle) {
if (handle instanceof InternalPipeHandle pipeHandle) {
return pipeHandle.hasDataAvailable();
}
if (handle instanceof StandardIO) {
// stdin: check System.in.available()
try {
return System.in.available() > 0;
} catch (Exception e) {
return false;
}
}
// For unknown handle types, report as ready to avoid blocking
return true;
}

/**
* Check if a write-end handle can accept writes without blocking.
*
* @param handle the IOHandle to check
* @return true if the handle can accept writes
*/
public static boolean isWriteReady(IOHandle handle) {
// Pipes and most handles can always accept writes (they buffer internally)
return true;
}
}
20 changes: 20 additions & 0 deletions src/main/java/org/perlonjava/runtime/io/IOHandle.java
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,26 @@ default RuntimeScalar flock(int operation) {
return RuntimeIO.handleIOError("flock operation is not supported on this handle type.");
}

/**
* Get the current blocking mode for this handle.
*
* @return true if the handle is in blocking mode (default), false if non-blocking
*/
default boolean isBlocking() {
return true;
}

/**
* Set the blocking mode for this handle.
*
* @param blocking true for blocking mode, false for non-blocking
* @return true if the mode was successfully set
*/
default boolean setBlocking(boolean blocking) {
// Default: only blocking mode is supported
return blocking;
}

// System-level I/O operations
default RuntimeScalar sysread(int length) {
return RuntimeIO.handleIOError("sysread operation is not supported.");
Expand Down
Loading