Skip to content

Commit 082ceee

Browse files
Fix regressions: goto in map/grep hang, parsimonious dup closes, glob-based handle lookup
Three fixes and comprehensive documentation: 1. op/array.t hang (176->0->176 FIXED): map/grep/all/any in ListOperators.java were missing control flow checks after RuntimeCode.apply(). When goto LABEL was used inside a map block, the RuntimeControlFlowList marker was silently discarded, causing an infinite loop (unshift grew the array each iteration). Added isNonLocalGoto() checks that propagate the marker to the caller. 2. io/dup.t regression (25->20->23 IMPROVED): Parsimonious dup (>&= / <&=) was returning the same RuntimeIO object, so close F on a parsimonious dup of STDOUT closed STDOUT itself. Created BorrowedIOHandle -- a non-owning wrapper that delegates all I/O but only flushes on close (never closes the delegate), matching Perl fdopen() semantics. 3. Named handle lookup via glob table: openFileHandleDup() now resolves named handles (STDIN/STDOUT/STDERR and user-defined) via GlobalVariable getGlobalIO() instead of a switch on static RuntimeIO fields. This is essential because standard handles can be redirected at runtime via open(STDOUT, ">file"). The glob table always reflects the current handle. 4. Thorough documentation added to all IO dup-related code including DupIOHandle, BorrowedIOHandle, FileDescriptorTable, and IOOperator methods. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 9be9c99 commit 082ceee

6 files changed

Lines changed: 556 additions & 96 deletions

File tree

src/main/java/org/perlonjava/core/Configuration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public final class Configuration {
3333
* Automatically populated by Gradle/Maven during build.
3434
* DO NOT EDIT MANUALLY - this value is replaced at build time.
3535
*/
36-
public static final String gitCommitId = "116d88c7a";
36+
public static final String gitCommitId = "69c74c914";
3737

3838
/**
3939
* Git commit date of the build (ISO format: YYYY-MM-DD).
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
package org.perlonjava.runtime.io;
2+
3+
import org.perlonjava.runtime.runtimetypes.RuntimeScalar;
4+
5+
import java.nio.charset.Charset;
6+
7+
import static org.perlonjava.runtime.runtimetypes.RuntimeIO.handleIOException;
8+
import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.scalarTrue;
9+
10+
/**
11+
* A non-owning IOHandle wrapper for Perl's parsimonious dup semantics ({@code >&=} / {@code <&=}).
12+
*
13+
* <h3>Background: parsimonious dup in Perl</h3>
14+
* <p>When Perl executes {@code open(F, ">&=STDOUT")}, it performs an {@code fdopen()} —
15+
* creating a new FILE* that shares the same fd as STDOUT. The key semantic difference
16+
* from a full dup ({@code >&}) is:</p>
17+
* <ul>
18+
* <li>Both handles share the <em>same</em> file descriptor (same fileno).</li>
19+
* <li>Closing the new handle ({@code close F}) does <em>not</em> close the underlying
20+
* resource — the original handle (STDOUT) remains fully operational.</li>
21+
* <li>This is a lightweight alias — no new OS-level file descriptor is allocated.</li>
22+
* </ul>
23+
*
24+
* <h3>Implementation</h3>
25+
* <p>BorrowedIOHandle delegates all I/O operations to the underlying delegate IOHandle,
26+
* but overrides {@link #close()} to only flush — never closing the delegate. This
27+
* ensures that after {@code close F}, the original handle (e.g. STDOUT) keeps working.</p>
28+
*
29+
* <p>Unlike {@link DupIOHandle}, this wrapper:</p>
30+
* <ul>
31+
* <li>Does NOT allocate a new fd number (shares the delegate's fileno)</li>
32+
* <li>Does NOT use reference counting (the delegate is never closed by us)</li>
33+
* <li>Is much simpler — just a thin delegation layer with a close-guard</li>
34+
* </ul>
35+
*
36+
* @see DupIOHandle for full dup semantics ({@code >&}) with reference counting
37+
* @see IOOperator#openFileHandleDup(String, String) where this is created
38+
*/
39+
public class BorrowedIOHandle implements IOHandle {
40+
41+
/** The underlying handle we're borrowing — never closed by us. */
42+
private final IOHandle delegate;
43+
/** Per-instance closed flag. Once true, all I/O operations on THIS wrapper fail. */
44+
private boolean closed = false;
45+
46+
/**
47+
* Creates a BorrowedIOHandle wrapping the given delegate.
48+
*
49+
* @param delegate the underlying IOHandle to borrow (not owned — will not be closed)
50+
*/
51+
public BorrowedIOHandle(IOHandle delegate) {
52+
this.delegate = delegate;
53+
}
54+
55+
/**
56+
* Returns the underlying delegate IOHandle.
57+
*/
58+
public IOHandle getDelegate() {
59+
return delegate;
60+
}
61+
62+
// ---- Delegated I/O operations (check closed state first) ----
63+
64+
@Override
65+
public RuntimeScalar write(String string) {
66+
if (closed) return handleClosed("write");
67+
return delegate.write(string);
68+
}
69+
70+
@Override
71+
public RuntimeScalar flush() {
72+
if (closed) return scalarTrue;
73+
return delegate.flush();
74+
}
75+
76+
@Override
77+
public RuntimeScalar sync() {
78+
if (closed) return scalarTrue;
79+
return delegate.sync();
80+
}
81+
82+
@Override
83+
public RuntimeScalar doRead(int maxBytes, Charset charset) {
84+
if (closed) return handleClosed("read");
85+
return delegate.doRead(maxBytes, charset);
86+
}
87+
88+
@Override
89+
public RuntimeScalar fileno() {
90+
if (closed) return handleClosed("fileno");
91+
// Return the delegate's fileno — parsimonious dup shares the same fd
92+
return delegate.fileno();
93+
}
94+
95+
@Override
96+
public RuntimeScalar eof() {
97+
if (closed) return scalarTrue;
98+
return delegate.eof();
99+
}
100+
101+
@Override
102+
public RuntimeScalar tell() {
103+
if (closed) return handleClosed("tell");
104+
return delegate.tell();
105+
}
106+
107+
@Override
108+
public RuntimeScalar seek(long pos, int whence) {
109+
if (closed) return handleClosed("seek");
110+
return delegate.seek(pos, whence);
111+
}
112+
113+
@Override
114+
public RuntimeScalar truncate(long length) {
115+
if (closed) return handleClosed("truncate");
116+
return delegate.truncate(length);
117+
}
118+
119+
@Override
120+
public RuntimeScalar flock(int operation) {
121+
if (closed) return handleClosed("flock");
122+
return delegate.flock(operation);
123+
}
124+
125+
@Override
126+
public RuntimeScalar bind(String address, int port) {
127+
if (closed) return handleClosed("bind");
128+
return delegate.bind(address, port);
129+
}
130+
131+
@Override
132+
public RuntimeScalar connect(String address, int port) {
133+
if (closed) return handleClosed("connect");
134+
return delegate.connect(address, port);
135+
}
136+
137+
@Override
138+
public RuntimeScalar listen(int backlog) {
139+
if (closed) return handleClosed("listen");
140+
return delegate.listen(backlog);
141+
}
142+
143+
@Override
144+
public RuntimeScalar accept() {
145+
if (closed) return handleClosed("accept");
146+
return delegate.accept();
147+
}
148+
149+
@Override
150+
public boolean isBlocking() {
151+
if (closed) return true;
152+
return delegate.isBlocking();
153+
}
154+
155+
@Override
156+
public boolean setBlocking(boolean blocking) {
157+
if (closed) return blocking;
158+
return delegate.setBlocking(blocking);
159+
}
160+
161+
@Override
162+
public RuntimeScalar sysread(int length) {
163+
if (closed) return handleClosed("sysread");
164+
return delegate.sysread(length);
165+
}
166+
167+
@Override
168+
public RuntimeScalar syswrite(String data) {
169+
if (closed) return handleClosed("syswrite");
170+
return delegate.syswrite(data);
171+
}
172+
173+
// ---- Close: flush only, do NOT close the delegate ----
174+
175+
/**
176+
* Closes this borrowed handle.
177+
*
178+
* <p>Only flushes the delegate — does NOT close the underlying resource.
179+
* This matches Perl's fdopen semantics where closing an fdopen'd FILE*
180+
* does not invalidate the original handle.</p>
181+
*/
182+
@Override
183+
public RuntimeScalar close() {
184+
if (closed) {
185+
return handleIOException(
186+
new java.io.IOException("Handle is already closed."),
187+
"Handle is already closed.");
188+
}
189+
closed = true;
190+
// Only flush — never close the delegate. The original handle still owns it.
191+
delegate.flush();
192+
return scalarTrue;
193+
}
194+
195+
private RuntimeScalar handleClosed(String operation) {
196+
return handleIOException(
197+
new java.io.IOException("Cannot " + operation + " on a closed handle."),
198+
operation + " on closed handle failed");
199+
}
200+
}

src/main/java/org/perlonjava/runtime/io/DupIOHandle.java

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,74 @@
99
import static org.perlonjava.runtime.runtimetypes.RuntimeScalarCache.*;
1010

1111
/**
12-
* A reference-counted IOHandle wrapper that enables proper filehandle duplication.
12+
* A reference-counted IOHandle wrapper that enables proper filehandle duplication
13+
* semantics — the Java equivalent of POSIX {@code dup(2)}.
1314
*
14-
* <p>When Perl does {@code open(SAVE, ">&STDERR")}, it creates a new file descriptor
15+
* <h3>Background: Perl's filehandle duplication</h3>
16+
* <p>When Perl executes {@code open(SAVE, ">&STDERR")}, it creates a new file descriptor
1517
* (via dup()) that shares the same underlying file description. Both fds are independent:
16-
* closing one does not affect the other. The underlying resource is only released when
17-
* ALL duplicates are closed.
18+
* closing one does not affect the other. The underlying OS resource (file, pipe, socket)
19+
* is only released when ALL duplicates are closed.</p>
1820
*
19-
* <p>This class implements that semantic by wrapping a delegate IOHandle with a shared
20-
* reference count. Each DupIOHandle tracks its own closed state. When close() is called,
21-
* the refcount is decremented; the delegate is only actually closed when the last
22-
* DupIOHandle is closed.
21+
* <p>Java doesn't expose POSIX file descriptors, so we simulate this behavior with
22+
* reference-counted wrappers around an underlying {@link IOHandle} delegate.</p>
23+
*
24+
* <h3>Architecture</h3>
25+
* <pre>
26+
* ┌──────────────────────┐ ┌──────────────────────┐
27+
* │ DupIOHandle (fd=1) │ │ DupIOHandle (fd=5) │
28+
* │ closed=false │ │ closed=false │
29+
* │ refCount ─────────────┼─────┼─▶ AtomicInteger(2) │
30+
* │ delegate ─────────────┼─────┼─▶ StandardIO │
31+
* └──────────────────────┘ └──────────────────────┘
32+
* (shared)
33+
* </pre>
34+
*
35+
* <h3>Lifecycle</h3>
36+
* <ol>
37+
* <li><b>Creation via {@link #createPair(IOHandle, int)}</b>: Called the first time
38+
* a handle is duplicated. Creates two DupIOHandles sharing the same delegate and
39+
* a new {@code AtomicInteger(2)} refCount. The first wrapper preserves the
40+
* original's fd; the second gets a new fd from {@link FileDescriptorTable}.</li>
41+
* <li><b>Subsequent dups via {@link #addDup(DupIOHandle)}</b>: Increments the shared
42+
* refCount and creates a new DupIOHandle with a new fd. No limit on how many
43+
* dups can be created.</li>
44+
* <li><b>Close</b>: Each DupIOHandle tracks its own {@code closed} flag. On close:
45+
* <ul>
46+
* <li>Marks itself as closed (further I/O operations will fail)</li>
47+
* <li>Unregisters its fd from {@link FileDescriptorTable}</li>
48+
* <li>Decrements the shared refCount</li>
49+
* <li>If refCount reaches 0 (last dup closed), actually closes the delegate</li>
50+
* <li>If refCount > 0 (other dups still open), just flushes the delegate</li>
51+
* </ul>
52+
* </li>
53+
* </ol>
54+
*
55+
* <h3>Thread safety</h3>
56+
* <p>The refCount uses {@link AtomicInteger} for thread-safe decrement. The {@code closed}
57+
* flag is per-instance and not synchronized — this matches the Perl model where each
58+
* filehandle is used by a single thread. If concurrent access is needed in the future,
59+
* the closed flag should be made volatile or synchronized.</p>
60+
*
61+
* <h3>fd number management</h3>
62+
* <p>Each DupIOHandle holds a synthetic fd number assigned by {@link FileDescriptorTable}.
63+
* The {@link #fileno()} method returns this fd (not the delegate's fd), so each duplicate
64+
* has a unique fileno as Perl expects. This fd is registered in FileDescriptorTable for
65+
* lookup by select() and in RuntimeIO for lookup by {@code findFileHandleByDescriptor()}.</p>
66+
*
67+
* @see IOOperator#duplicateFileHandle(RuntimeIO) where DupIOHandles are created
68+
* @see IOOperator#openFileHandleDup(String, String) entry point for Perl's open() dup modes
69+
* @see FileDescriptorTable synthetic fd allocation and lookup
2370
*/
2471
public class DupIOHandle implements IOHandle {
2572

73+
/** The underlying I/O implementation (StandardIO, FileIOHandle, etc.) — never another DupIOHandle. */
2674
private final IOHandle delegate;
75+
/** Shared across all dups of the same delegate. Decremented on close; delegate closed at zero. */
2776
private final AtomicInteger refCount;
77+
/** Per-instance closed flag. Once true, all I/O operations on THIS dup return errors. */
2878
private boolean closed = false;
79+
/** Synthetic fd number unique to this dup, assigned by FileDescriptorTable. */
2980
private final int fd;
3081

3182
/**
@@ -202,6 +253,19 @@ public RuntimeScalar syswrite(String data) {
202253

203254
// ---- Close with reference counting ----
204255

256+
/**
257+
* Closes this duplicate handle.
258+
*
259+
* <p>Semantics:
260+
* <ol>
261+
* <li>If already closed, returns an error (matches Perl's "close on closed fh").</li>
262+
* <li>Marks this instance as closed — subsequent I/O operations will fail.</li>
263+
* <li>Unregisters this fd from {@link FileDescriptorTable}.</li>
264+
* <li>Decrements the shared refCount atomically.</li>
265+
* <li>If this was the <em>last</em> duplicate (refCount → 0), closes the delegate.</li>
266+
* <li>Otherwise, just flushes the delegate to ensure buffered data is written.</li>
267+
* </ol>
268+
*/
205269
@Override
206270
public RuntimeScalar close() {
207271
if (closed) {

src/main/java/org/perlonjava/runtime/io/FileDescriptorTable.java

Lines changed: 44 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,60 @@
66
import org.perlonjava.runtime.runtimetypes.RuntimeIO;
77

88
/**
9-
* Maps simulated file descriptor numbers to IOHandle objects.
9+
* Maps simulated file descriptor numbers to {@link IOHandle} objects.
1010
*
11-
* <p>Java doesn't expose real POSIX file descriptors. This table assigns
12-
* sequential integers starting from 3 (0, 1, 2 are reserved for
13-
* stdin, stdout, stderr) and allows lookup by FD number.
11+
* <h3>Why this exists</h3>
12+
* <p>Java doesn't expose real POSIX file descriptors. Perl code, however, relies on
13+
* numeric fd values for operations like {@code fileno()}, {@code select()}, and
14+
* {@code open(FH, ">&=", $fd)}. This table assigns sequential integers starting
15+
* from 3 (0, 1, 2 are reserved for stdin, stdout, stderr) and provides fd→IOHandle
16+
* lookup.</p>
1417
*
15-
* <p>Used by:
16-
* <ul>
17-
* <li>{@code fileno()} — to return a consistent FD for each handle</li>
18-
* <li>4-arg {@code select()} — to map bit-vector bits back to handles</li>
19-
* </ul>
18+
* <h3>Relationship to other fd registries</h3>
19+
* <p>There are <b>three</b> fd-related registries in the system, each serving a
20+
* different purpose:</p>
21+
* <table border="1">
22+
* <tr><th>Registry</th><th>Maps</th><th>Used by</th></tr>
23+
* <tr>
24+
* <td>{@code FileDescriptorTable} (this class)</td>
25+
* <td>fd → {@link IOHandle}</td>
26+
* <td>{@code select()}, {@link DupIOHandle} registration</td>
27+
* </tr>
28+
* <tr>
29+
* <td>{@code RuntimeIO.filenoToIO}</td>
30+
* <td>fd → {@link RuntimeIO}</td>
31+
* <td>{@link IOOperator#findFileHandleByDescriptor(int)} fallback</td>
32+
* </tr>
33+
* <tr>
34+
* <td>{@code IOOperator.fileDescriptorMap}</td>
35+
* <td>fd → {@link RuntimeIO}</td>
36+
* <td>{@link IOOperator#findFileHandleByDescriptor(int)} first check</td>
37+
* </tr>
38+
* </table>
2039
*
21-
* <p>Thread-safe: uses ConcurrentHashMap and AtomicInteger.
40+
* <p>These registries are kept in sync via {@link #advancePast(int)} and
41+
* {@link RuntimeIO#advanceFilenoCounterPast(int)} to prevent fd collisions between
42+
* handles allocated by different subsystems (e.g., DupIOHandle vs. socket/pipe).</p>
43+
*
44+
* <h3>Thread safety</h3>
45+
* <p>Uses {@link ConcurrentHashMap} and {@link AtomicInteger} for thread-safe access.</p>
46+
*
47+
* @see DupIOHandle uses this table for fd allocation and registration
48+
* @see IOOperator uses this table indirectly via DupIOHandle
49+
* @see RuntimeIO parallel fd→RuntimeIO registry for higher-level lookups
2250
*/
2351
public class FileDescriptorTable {
2452

25-
// Next FD number to assign. 0–2 are stdin/stdout/stderr.
53+
/** Next fd to allocate. Starts at 3 (0=stdin, 1=stdout, 2=stderr are reserved). */
2654
private static final AtomicInteger nextFd = new AtomicInteger(3);
2755

28-
// FD number → IOHandle (for select() lookup)
56+
/** Forward map: fd number → IOHandle. Used by select() to find handles from fd bits. */
2957
private static final ConcurrentHashMap<Integer, IOHandle> fdToHandle = new ConcurrentHashMap<>();
3058

31-
// IOHandle identity → FD number (to avoid assigning multiple FDs to the same handle)
59+
/**
60+
* Reverse map: IOHandle identity hash → fd number.
61+
* Prevents assigning multiple fds to the same IOHandle object (identity, not equality).
62+
*/
3263
private static final ConcurrentHashMap<Integer, Integer> handleToFd = new ConcurrentHashMap<>();
3364

3465
/**

0 commit comments

Comments
 (0)