diff --git a/dev/design/concurrency.md b/dev/design/concurrency.md index c7d3cedf2..8ef9e4f33 100644 --- a/dev/design/concurrency.md +++ b/dev/design/concurrency.md @@ -663,24 +663,17 @@ record counts and linear scaling. ### Phase 36 — Complete regex parity exercised by thread wrappers (in progress) -Finish embedded and dynamic regex code, optimistic evaluation, conditionals, -control verbs, recursive definitions, lookbehind, Unicode properties, and -`qr//` diagnostics in the shared parser/runtime. Direct language behavior is -the target; `_thr.t` wrappers receive no special cases. Acceptance: every -applicable `perl5_t/t/re/*thr*.t` test and direct companion completes its plan -without unexpected failure on JVM or interpreter backends. - -This tranche restores recursive-definition compilation for `reg_email` and -keeps direct/thread compilation behavior aligned. DATA now models the source -file positioned after its marker, remains seekable to the source start, and -crosses thread snapshots through the named-handle inheritance policy. Direct -and threaded `reg_email` therefore pass 13/13 on both backends. `pat_re_eval` -now parses quoted code-block-shaped text correctly: `(?{` inside `\Q...\E` -is literal rather than an embedded Perl block. This advances both direct and -threaded files to runtime construction, where arbitrary match-time `(?{...})` -execution remains the next blocker. The remaining `qr//`, conditional, -control-verb, lookbehind, Unicode-property, and diagnostic coverage likewise -remains shared regex-language work. +Phase 36 is now an independent parallel project documented in +`dev/design/phase36-regex-parity.md`. It owns executable callbacks, dynamic +patterns, conditionals, control verbs, lookbehind, Unicode properties, regex +objects, and diagnostics in the shared direct-language implementation. Thread +wrappers remain unchanged acceptance tests and receive no special cases. + +The concurrency project consumes Phase 36 through focused integration slices +and preserves Phase 44's green thread matrix. Current completed foundations and +the callback-capable matcher architecture, staged implementation plan, direct +and wrapper gates, risks, and resumable next steps are maintained in the +separate Phase 36 document. ### Phase 37 — General filehandle and resource inheritance (implemented tranche 2026-08-14) @@ -743,6 +736,28 @@ runtime that releases the final cross-runtime owner. Separate destructive plain includes nested rebless/store-back, fresh `refaddr` views, cycles, weak refs, one global destructor, and share-versus-shared_clone system-Perl oracles. +Fetch-time aggregate proxies are implemented: every nested array/hash reference +read from shared storage receives a fresh runtime-local wrapper over canonical +synchronized backing. Local reblessing is private until the view is stored +back, cycles retain common storage without retaining wrapper identity, and a +weak fetched view can disappear without deleting the canonical value. The +focused ten-assertion oracle passes on system Perl and both PerlOnJava backends. +Cross-runtime final-owner destruction is now implemented. Canonical shared +storage and its fetched runtime-local wrappers carry one atomic lifecycle: +canonical deletion waits for live fetched views, releasing a non-final view +does not run `DESTROY`, and the runtime releasing the final owner performs the +callback exactly once. The ordering is race-safe when canonical deletion and +the last view release happen concurrently. A system-Perl-valid five-assertion +oracle passes on JVM and interpreter backends in addition to the existing +ten-assertion nested-proxy matrix. + +Destructive plain `share` remains the one explicit compatibility transition. +The existing immutable test records the older Java-specific preserving +behavior, while system Perl clears scalar/array/hash contents and reserves +preserving recursive publication for `shared_clone`. The implementation must +not silently invert that assertion; the test contract needs an explicit user +decision before production behavior changes. + ### Phase 40 — Complete public `threads` API (completed 2026-08-14) Close every remaining lifecycle, signal, context, exit-status, stack-size, @@ -777,7 +792,7 @@ benefit gates. The post-reset regression gate retains all 325 DBIx::Class files and 42,671 assertions under `./jcpan --jobs 8 -t DBIx::Class`. -### Phase 42 — Opt-in pooling and concurrent PSGI +### Phase 42 — Opt-in pooling and concurrent PSGI (completed 2026-08-15) Add a bounded runtime-family pool configured by `-Djperl.runtime.pool.size=N` or `JPERL_RUNTIME_POOL_SIZE` (default zero). @@ -787,14 +802,28 @@ snapshot runtimes and streaming responses retain their runtime until completion; `psgi.multithread` is true only in that mode. Pooling must improve create/join or request median time by at least 10% without a hot-path regression above 5%. -### Phase 43 — Virtual threads by default +The bounded pool defaults to zero, enforces exclusive leases, resets reusable +core runtimes, replaces poisoned entries, and never resets an active lease. +Netty prebuilds independent application snapshots, checks one out per request, +retains it through streaming dispatch, and advertises `psgi.multithread` only +in pooled mode. Three controlled eight-request runs measured about 74% lower +median completion time with four pooled runtimes; a retention probe collected +the returned tenant graph and replaced state holder. + +### Phase 43 — Virtual threads by default (completed 2026-08-15) Make Java virtual threads the default while retaining `JPERL_THREAD_MODE=platform`. Explicit nonzero stack sizing transparently uses a platform child. Validate FFM/JDBC, sockets, process I/O, regex timeouts, shared conditions, callbacks, pooling, and detached shutdown under both modes. -### Phase 44 — Release closure +The Java 24 launcher now defaults to virtual carriers and preserves explicit +platform selection. A nonzero stack request selects a platform child rather +than discarding the option. The mandatory unit build is green, and the focused +core lifecycle, class, and lexical-regex matrix passes 40/40 in both default +virtual and explicit platform modes. + +### Phase 44 — Release closure (completed 2026-08-15) Run the complete core and bundled thread matrix, DBI/DBIx, Test2 opt-in stress, Storable, and native callback gates. Add a DBI example where each child owns its @@ -803,6 +832,32 @@ only from captured results. Acceptance requires `make`, link checks, all thread gates, `timeout 3600 ./jcpan --jobs 8 -t DBIx::Class`, and green Ubuntu/Windows CI. +Release evidence collected on 2026-08-15: the 17-file core thread-wrapper +matrix completed without timeout; the supported anchors include `threads.t` +30/30, `index_thr.t` 415/415, `substr_thr.t` 400/400, lexical regex debugging +6/6, the user-property race 3/3, and `pat_psycho_thr.t` 17/17. Remaining partial +regex totals match the shared direct-language gaps recorded in Phase 36 rather +than thread-only failures. + +The default Test2 matrix passes 13 files and 38/38 assertions. All ten opt-in +Test2 stress files now pass 562/562 assertions after preserving cloned closure +capture ownership, rooting the active child CODE during lifecycle sweeps, and +recovering the active method package for nested `SUPER` dispatch. Storable +passes 2/2, and the two Net::SSLeay callback/context stress gates pass 1/1 +each. New system-Perl-valid lifetime, nested-method, and weak-assignment +oracles pass 8/8 on both JVM and interpreter backends. The child-owned DBI +connection example passes with identical output on system Perl, JVM, and +interpreter. + +The mandatory unit build and documentation link validation are green. The +required `timeout 3600 ./jcpan --jobs 8 -t DBIx::Class` gate passes all 325 +files and 42,671 assertions in 1,371 seconds. The broad bundled-module task +remains red at 239/391 files; its 152 failures span unrelated unported +Text::CSV/YAML and other module behavior, while the thread-specific bundled +Storable and Net::SSLeay gates above are green. It is recorded as a wider +project baseline, not presented as threads release success. Ubuntu and Windows +CI remain the final external PR acceptance check. + ## 6. Known Reference Material and Warnings - `dev/prompts/multiplicity-v2-plan.md` documents the incremental response to @@ -817,7 +872,7 @@ CI. ## 7. Progress Tracking -### Current Status: Phase 41 complete; Phase 36 and Phase 39b remain open +### Current Status: Phase 44 local release gate complete; Phases 36 and 39b remain open Hints, warnings, filters, and source maps are runtime-owned while compiler-only scratch remains protected by the global compile lock. The Phase 11 inventory is @@ -915,14 +970,12 @@ modules are 9966/12339 (80.8%). Scalar::Util 1.70 loads on both backends and the JVM Moo constructor smoke passes; the interpreter retains its previously documented Moo attribute-syntax parser limitation. -Platform threads remain the default. An experimental process-wide opt-in selects -virtual threads with `-Djperl.thread.mode=virtual` or -`JPERL_THREAD_MODE=virtual`; unknown modes are rejected and diagnostics expose -the actual Java thread kind. Runtime pooling is deferred because no reset -contract yet proves that a reused interpreter is equivalent to a fresh snapshot. -The supported Java baseline is 24. Monitor pinning is removed on that baseline, -but native/FFM blocking diagnostics remain a release gate for promoting virtual -mode. +Java 24 virtual threads are the launcher default. Explicit +`JPERL_THREAD_MODE=platform` selection remains supported, and a nonzero Perl +stack-size request transparently selects a platform child. Unknown modes are +rejected and diagnostics expose the actual Java thread kind. Runtime pooling +is opt-in and defaults to zero after passing Phase 42's stress, retention, and +concurrent-request performance gates. The public documentation now treats the feature matrix as the canonical support table, records the implementation in the changelog and roadmap, explains runtime @@ -995,16 +1048,18 @@ three assertions from the adjacent-import parser fix. ### Next Steps -1. Complete Phase 36's direct regex-language gaps in `pat_re_eval`, `qr//`, - conditionals, control verbs, lookbehind, Unicode properties, and diagnostics; - wrapper behavior must follow the corrected direct implementation without - special cases. -2. Implement Phase 39b's fetch-time nested shared proxies, global destruction, - weak/cyclic ownership, and the destructive `share` versus preserving - `shared_clone` distinction. -3. Land Phases 42–44 as the final delivery sequence: opt-in pooling and - concurrent PSGI, virtual threads by default, and the complete release gate. - Phases 40 and 41's public API and fresh-reset foundations are complete. +1. Integrate Phase 36 slices from the independent regex project described in + `dev/design/phase36-regex-parity.md`. Direct regex behavior is fixed first; + unchanged wrappers remain the thread ownership and snapshot gate. +2. Resolve Phase 39b's sole compatibility decision: whether to replace the + immutable Java-specific preserving-`share` assertion with system Perl's + destructive initialization. Nested proxies, runtime-local blessing, + cycles, weak views, and exactly-once cross-runtime destruction are complete; + `shared_clone` remains preserving. +3. Preserve the completed Phase 44 matrix while Phase 36 and Phase 39b close: + core thread wrappers must finish without timeout; default and opt-in Test2, + Storable, Net::SSLeay, DBI, and DBIx::Class must remain green on every PR; + Ubuntu and Windows CI are mandatory delivery gates. 4. Preserve the green core, Storable, Test2, Net::SSLeay, index/substr, DBI, and DBIx::Class anchors after every phase. The 2026-08-14 DBIx::Class gate passed all 325 files and 42,671 assertions under @@ -1028,4 +1083,5 @@ three assertions from the adjacent-import parser fix. - `dev/design/clone.md` — historical clone exploration, not the ithread cloner - `dev/design/attributes.md` — current attribute behavior - `dev/design/runtime-pooling-reset-contract.md` — required proof before pooling +- `dev/design/phase36-regex-parity.md` — independent regex parity project - `dev/design/fork_open_emulation.md` — process/fork-related alternatives diff --git a/dev/design/phase36-regex-parity.md b/dev/design/phase36-regex-parity.md new file mode 100644 index 000000000..64f6f7fe2 --- /dev/null +++ b/dev/design/phase36-regex-parity.md @@ -0,0 +1,275 @@ +# Phase 36: Complete Regex Parity + +## Status + +- **Project status:** Planned as a parallel project +- **Current stage:** Baseline refresh and architecture validation +- **Parent plan:** `dev/design/concurrency.md` +- **Detailed callback design:** `dev/design/executable-regex-callbacks.md` +- **Integration rule:** Direct regex semantics are implemented first. Thread + wrappers receive no compatibility branches or test-specific behavior. + +## Objective + +Complete the shared Perl regex implementation exercised by direct core tests and +their `_thr.t` wrappers. The result must behave consistently on the JVM and +interpreter backends, preserve runtime ownership across ithread snapshots, and +retain the existing fast paths for patterns that do not need advanced features. + +Phase 36 is intentionally independent from the remaining threads delivery work. +It should be developed in a separate checkout and integrated through focused +regex PRs. The concurrency project consumes its results through unchanged +thread wrappers and protects the Phase 44 release matrix. + +## Completed Foundation + +- Lexical `use/no re 'debug'` and `debugcolor` state is runtime-owned and works + across thread snapshots. +- Recursive definitions used by `reg_email` compile on the recursive backend. +- DATA handles retain the source position and inheritance behavior needed by + direct and threaded regex fixtures. +- Quoted `(?{` text inside `\Q...\E` is not parsed as executable code. +- Literal regex expressions assembled from strings, concatenation, and + `quotemeta` are validated when the surrounding CV is compiled. +- `(*FAIL)` and `(*F)` have real always-failing zero-width behavior. +- Runtime-owned Unicode property caches and per-runtime-family property + coordination are implemented. +- Phase 44 established timeout-free thread-wrapper execution and the supported + regex anchors, including lexical debugging 6/6, user-property race 3/3, and + `pat_psycho_thr.t` 17/17. + +## Remaining Workstreams + +### A. Executable regex callbacks + +Implement match-time `(?{ BLOCK })`, callback conditions `(?(?{ BLOCK })yes|no)`, +optimistic evaluation `(*{ BLOCK })`, and dynamic patterns `(??{ EXPR })`. +Callbacks must run while the matcher owns its provisional captures and +backtracking stack. Post-match callbacks and construction-time execution are not +acceptable approximations. + +The architecture, semantic matrix, Joni callout proposal, and callback-specific +risks live in `dev/design/executable-regex-callbacks.md`. Revalidate that +document's historical prerequisites and library-version assumptions before +implementation. + +### B. Conditionals and control verbs + +Complete non-callback conditionals and the ACCEPT, PRUNE, SKIP, THEN, and COMMIT +families. Define their capture, `pos`, alternation, and backtracking effects with +standard-Perl differential tests. Engine rewrites are allowed only when they +preserve those effects; otherwise route the pattern to a capable backend. + +### C. Lookbehind, recursion, and nested programs + +Close remaining variable-length lookbehind, recursive-call, nested-regex, and +capture-numbering gaps. Declarative recursion and executable dynamic patterns +must share consistent recursion limits and timeout behavior without relying on +unbounded Java stack recursion. + +### D. Unicode properties + +Finish direct Unicode property semantics before attributing wrapper failures to +threads. Cover built-in and user-defined properties, cache identity, warnings, +exceptions, recursion, concurrent unrelated names, and same-name coordination. +Preserve runtime-local results and snapshot policy. + +### E. `qr//`, diagnostics, and match state + +Complete regex object interpolation/stringification, `/g`, `/c`, `/o`, +substitution state, warning text, source locations, compile errors, byte/Unicode +targets, and nested match-state restoration. Internal callback identifiers must +never leak through stringification or diagnostics. + +### F. Direct/thread parity and policy removal + +After a direct test passes, run its unchanged thread wrapper on both backends. +Remove capability policies, CPAN patches, or skips only when the unchanged +source-first gate passes. A wrapper may configure resources and timeouts, but it +must not change expected regex behavior. + +## Implementation Stages + +### Stage 36.0 — Refresh the differential baseline + +1. Record same-commit direct and thread-wrapper counts with the standard runner. +2. Separate direct language gaps from clone/runtime-ownership gaps. +3. Add standard-Perl-valid focused tests for every proposed semantic change. +4. Capture backend fallback and timeout behavior for each target. + +**Exit criteria:** Every target failure is classified by feature and backend; +the baseline has no orphaned JVMs or unexplained timeout-only zero-TAP results. + +### Stage 36.1 — Validate the callback engine seam + +1. Refresh the Joni callout spike against the currently shipped matcher layer. +2. Prove a runtime-neutral callout can observe provisional captures, repeat after + backtracking, and receive an exact unwind notification. +3. Decide whether to publish a namespaced fork or propose the generic callout API + upstream. + +**Exit criteria:** The spike demonstrates forward execution and backtracking +unwind without PerlOnJava dependencies inside the regex engine. + +### Stage 36.2 — Structured frontend and callback templates + +1. Preserve callback Perl ASTs instead of flattening them into marker strings. +2. Compile callback bodies as lexical `RuntimeCode` values on both backends. +3. Build per-regex callback tables with collision-proof internal skeletons. +4. Keep unsupported execution fatal until the matcher bridge is present. + +**Exit criteria:** JVM and interpreter construct equivalent closure-bearing +templates, with correct lexical identity and snapshot ownership. + +### Stage 36.3 — Plain callbacks and provisional match state + +Implement `(?{ BLOCK })`, `$^R`, provisional numbered/named captures, `$^N`, +`@-`, `@+`, `$_`, and `pos`. Add an active match-state stack so callbacks may +run nested regexes without destroying the outer provisional state. + +**Exit criteria:** The focused plain-callback matrix and relevant unchanged +Type::Tiny tests pass on system Perl, JVM, and interpreter. + +### Stage 36.4 — Backtracking, dynamic scope, and callback conditions + +Add matcher-owned dynamic-local checkpoints and exact-once unwind for success, +failure, alternatives, quantifiers, lookarounds, exceptions, interruption, and +timeout. Implement callback conditions only after this unwind model is proven. + +**Exit criteria:** Applicable `rxcode.t`, `reg_eval_scope.t`, and +Regexp::Common callback-condition sections match standard Perl. + +### Stage 36.5 — Dynamic patterns and recursive execution + +Implement `(??{ EXPR })` as a nested matcher program whose alternatives +participate in outer backtracking. Specify returned string versus `qr//` values, +capture numbering, modifier inheritance, caching, recursion limits, and `/o`. + +**Exit criteria:** The focused dynamic-pattern matrix, Object::InsideOut's +recursive pattern, and applicable `reg_eval.t`/`rxcode.t` sections pass. + +### Stage 36.6 — Remaining declarative parity + +Complete control verbs, conditionals, lookbehind, Unicode properties, regex +objects, state, and diagnostics. Prefer isolated feature slices with direct +oracles over broad changes to `RegexPreprocessor`. + +**Exit criteria:** Direct target files complete their expected plans on both +backends, with no regression in ordinary Java-regex or declarative Joni paths. + +### Stage 36.7 — Integration and release + +1. Run all applicable direct and `_thr.t` companions. +2. Run `make` and the Phase 44 thread release matrix. +3. Run unchanged CPAN suites whose policies are being removed. +4. Update the feature matrix, changelog, and regex implementation documents. +5. Require green Ubuntu and Windows CI before merging each release slice. + +**Exit criteria:** Target suites and wrappers have captured passing evidence; +removed policies are justified by unchanged-source results; Phase 44 anchors +remain green. + +## Test Matrix + +### Focused core targets + +- `perl5_t/t/re/pat_re_eval.t` +- `perl5_t/t/re/rxcode.t` +- `perl5_t/t/re/reg_eval.t` +- `perl5_t/t/re/reg_eval_scope.t` +- `perl5_t/t/re/pat.t` +- `perl5_t/t/re/pat_advanced.t` +- `perl5_t/t/re/regexp_qr_embed.t` +- `perl5_t/t/re/regexp_unicode_prop.t` +- `perl5_t/t/re/speed.t` +- Every applicable `_thr.t` companion + +### CPAN targets + +- Type::Tiny callback tests without callback capability patches +- Regexp::Common callback and dynamic-pattern tests +- Object::InsideOut recursive-pattern tests +- Any distribution whose policy is removed by a Phase 36 slice + +### Preservation gates + +- Full `make` +- Phase 44 core thread-wrapper matrix without timeout +- Test2 default and opt-in stress +- Storable and Net::SSLeay thread gates +- DBI ownership tests and `timeout 3600 ./jcpan --jobs 8 -t DBIx::Class` +- Ubuntu and Windows CI + +All new Perl unit tests must first pass under system Perl. Every `jperl`, +`jcpan`, and `prove` investigation must be hard-timeout-wrapped and captured to +a file. Resource-sensitive tests stay in the runner's exclusive lane, and a +timing delta is a regression only after a serialized same-commit reproduction. + +## Parallel-Project Boundaries + +- Work in a separate checkout and feature branch; do not share build artifacts + or active test processes with the concurrency release branch. +- Keep callback-engine dependency changes separate from semantic frontend/runtime + changes where practical, so the fork surface can be reviewed independently. +- Do not edit thread wrappers to manufacture parity. Fix the direct regex path, + then use wrappers as snapshot/ownership acceptance tests. +- Rebase before each integration slice and rerun the focused direct/thread pair + on the rebased commit. +- Update this file after each completed stage with dates, exact test evidence, + blockers, and the next resumable action. The concurrency plan should contain + only the cross-project status and Phase 44 preservation contract. + +## Risks and Stop Conditions + +- Stop if the matcher cannot expose provisional captures and unwind points; do + not replace callbacks with post-match execution. +- Stop if dynamic patterns are atomic and cannot yield alternatives to outer + backtracking. +- Keep unsupported syntax fatal if correct semantics are unavailable. +- Preserve separate cached matcher structure and per-value lexical callback + tables to prevent closure identity leaks. +- Do not allocate callback state on ordinary-pattern fast paths. +- Treat timeout, interruption, nested match, and non-local control flow cleanup + as correctness requirements, not later optimizations. + +## Progress Tracking + +### Current Status: Stage 36.0 ready + +### Completed stages + +- [ ] Stage 36.0: Refresh differential baseline +- [ ] Stage 36.1: Validate callback engine seam +- [ ] Stage 36.2: Structured frontend and callback templates +- [ ] Stage 36.3: Plain callbacks and provisional match state +- [ ] Stage 36.4: Backtracking, dynamic scope, and conditions +- [ ] Stage 36.5: Dynamic patterns and recursive execution +- [ ] Stage 36.6: Remaining declarative parity +- [ ] Stage 36.7: Integration and release + +### Next steps + +1. Refresh direct and wrapper counts on the Phase 36 checkout. +2. Create and validate the callback semantic matrix with system Perl. +3. Revalidate the Joni callout spike against the current matcher abstraction. +4. Record the fork/upstream decision before starting structured frontend work. + +### Open blockers + +- The current Java/Joni matcher paths do not expose Perl callouts during + backtracking. +- Several callback-localization and dynamic-pattern capture rules still require + standard-Perl differential evidence. +- The detailed callback design contains historical PR and library assumptions + that must be refreshed during Stage 36.0. + +## Related Documents and Skills + +- `dev/design/executable-regex-callbacks.md` — detailed callback architecture +- `dev/design/concurrency.md` — parent threads plan and Phase 44 gates +- `dev/design/regex_jruby_joni.md` — Joni integration notes +- `dev/design/regex_parser_integration.md` — parser/AST strategy +- `dev/design/regex_preprocessing_fixes.md` — preprocessing gaps and baselines +- `dev/design/regex_alternatives.md` — backend alternatives +- `dev/implementation/regex.md` — earlier matcher architecture +- `.agents/skills/debug-perlonjava/SKILL.md` — differential debugging workflow diff --git a/dev/design/runtime-pooling-reset-contract.md b/dev/design/runtime-pooling-reset-contract.md index 6dcdb3a25..4f14906ff 100644 --- a/dev/design/runtime-pooling-reset-contract.md +++ b/dev/design/runtime-pooling-reset-contract.md @@ -2,20 +2,19 @@ ## Status -Fresh-runtime reset was implemented on 2026-08-14. Runtime pooling remains -deliberately disabled until Phase 42's checkout stress, retention measurements, -and performance gate pass. `PerlRuntime.close()` remains a terminal +Fresh-runtime reset was implemented on 2026-08-14. Bounded opt-in pooling was +activated on 2026-08-15 after Phase 42's checkout stress, retention measurements, +and concurrent-request performance gate passed. `PerlRuntime.close()` remains a terminal resource-release operation; reusable runtimes use the separate exclusive `reset()` transition. -This document defines the proof required before a pool may be implemented. It -does not authorize clearing state opportunistically or enabling pooling behind -an experimental flag. +This document defines the proof required by the implemented pool. It does not +authorize clearing state opportunistically or enabling pooling by default. This is the Phase 34 outcome, not an untracked implementation shortcut. Runtime pooling is optional and is not required for Perl ithread correctness. The -negative automated contract below passes, while every positive equivalence -item remains a prerequisite for any future pooling PR. +negative and positive automated contracts below pass. Pooling remains optional +and defaults to zero. ## Fresh-runtime equivalence @@ -62,7 +61,7 @@ correct terminal lifecycle, but it is intentionally insufficient for pooling. ## Acceptance checklist -Pooling remains disabled until all items below are complete: +Pooling remains disabled by default; opt-in activation requires all items below: - [x] Introduce one exclusive lifecycle transition that prevents reset while execution, compilation, callbacks, ithreads, detached children, shared-lock @@ -84,11 +83,11 @@ Pooling remains disabled until all items below are complete: - [x] Prove `A; reset; B == fresh; B` on both compiler backends across globals, closures, eval/require, regex, warnings/hints, MRO, I/O, lifecycle, signals, native modules, DATA, debugger state, and exceptions. -- [ ] Add concurrency/stress coverage for checkout ownership, cancellation, +- [x] Add concurrency/stress coverage for checkout ownership, cancellation, detached-thread completion, repeated churn, and shared-storage lifetime. -- [ ] Add retention measurements showing that workload classloaders, package +- [x] Add retention measurements showing that workload classloaders, package graphs, handles, and shared-lock entries become collectible after reset. -- [ ] Benchmark against fresh construction and snapshot cloning. Enable pooling +- [x] Benchmark against fresh construction and snapshot cloning. Enable pooling only for a measured benefit large enough to justify the new lifecycle risk. ## Current automated guard @@ -100,6 +99,16 @@ fresh runtime on both backends; reset rejects bindings, child threads, and shared locks; pending END work drains; failed reset poisons the runtime; and the same Java runtime identity executes again after successful reset. +`PerlRuntimePoolTest` covers exclusive bounded checkout, reset on return, +active-lease shutdown, replacement after reset failure, and concurrent churn. +An EmbeddedChannel acceptance test proves that pooled PSGI requests receive +distinct application snapshots and truthful `psgi.multithread`. A bounded +retention probe collected both a prior tenant graph and its replaced state +holder. Three concurrent-request benchmark runs measured approximately 118 ms +for one serialized runtime versus 30.6 ms for four pooled runtimes (about 74% +lower median completion time). Empty-runtime reset churn is not faster than +construction, so no universal microbenchmark speedup is claimed. + ## Related documents - `dev/design/concurrency.md` — multiplicity, ithreads, and virtual-thread policy diff --git a/docs/about/changelog.md b/docs/about/changelog.md index 9544dda13..5763d14f2 100644 --- a/docs/about/changelog.md +++ b/docs/about/changelog.md @@ -16,8 +16,8 @@ Release history of PerlOnJava. See [Roadmap](roadmap.md) for future plans. inspection, nested threads, child-only exit, `CLONE`/`CLONE_SKIP`, recursive locks, condition variables, and compatible imports/stringification. `Config` now reports `useithreads`, `usethreads`, and `usemultiplicity` as - `define`. Platform threads remain the default and virtual threads are an - experimental opt-in. Live attached children support targeted signals, + `define`. Java 24 virtual threads are the default and platform carriers remain + an explicit compatibility mode. Live attached children support targeted signals, `object`/`wantarray`, and platform-thread stack sizing. Native-style callback registrations retain their owning runtime, internal pipes have an explicit inherited-handle policy, and nested plain shared graphs are validated before diff --git a/docs/about/roadmap.md b/docs/about/roadmap.md index b1872571e..bb38f99a2 100644 --- a/docs/about/roadmap.md +++ b/docs/about/roadmap.md @@ -283,8 +283,8 @@ Implement `fork()` via runtime cloning + thread. Currently returns `undef`. The supported ithread tranche is shipped: snapshot-based variable isolation, create/join/detach and lifecycle inspection, nested threads and child exit, -`threads::shared` storage, recursive locks, and condition variables. Platform -threads are the default; virtual threads are experimental. +`threads::shared` storage, recursive locks, and condition variables. Java 24 +virtual threads are the default; platform carriers remain selectable. Remaining work: diff --git a/docs/reference/cli-options.md b/docs/reference/cli-options.md index c7f4b3aeb..c13ce3144 100644 --- a/docs/reference/cli-options.md +++ b/docs/reference/cli-options.md @@ -210,25 +210,40 @@ jperl [options] [program | -e 'command'] [arguments] ### Thread execution mode - **`JPERL_THREAD_MODE`** — Select the Java carrier used by newly created - Perl ithreads. `platform` is the stable default; `virtual` enables the - experimental virtual-thread policy. + Perl ithreads. `virtual` is the Java 24 launcher default; `platform` selects + the retained platform-thread policy. ```bash - JPERL_THREAD_MODE=virtual ./jperl threaded.pl + JPERL_THREAD_MODE=platform ./jperl threaded.pl ``` -The equivalent JVM property is `-Djperl.thread.mode=virtual`, supplied through +The equivalent JVM property is `-Djperl.thread.mode=platform`, supplied through `JPERL_OPTS` when using the launcher: ```bash -JPERL_OPTS='-Djperl.thread.mode=virtual' ./jperl threaded.pl +JPERL_OPTS='-Djperl.thread.mode=platform' ./jperl threaded.pl ``` -Virtual mode currently claims semantic parity only. It has not demonstrated a -material speedup and still requires native-I/O diagnostics on the supported -Java 24 baseline described in the +The carrier selection does not change Perl snapshot or shared-storage +semantics. A nonzero per-thread stack request automatically selects a platform +carrier because virtual-thread stacks are JVM-managed. See the [concurrency feature matrix](feature-matrix.md#concurrency-and-perl-threads). +### Runtime pooling + +- **`JPERL_RUNTIME_POOL_SIZE`** — Prebuild a bounded number of independent + runtime snapshots for concurrent PSGI requests. The default is `0`, which + keeps the single-runtime handler. Negative and nonnumeric values are errors. + + ```bash + JPERL_RUNTIME_POOL_SIZE=4 ./jperl app.psgi + ``` + +The equivalent JVM property is `-Djperl.runtime.pool.size=4`. A checked-out +runtime is never entered by another request. Core-runtime users are reset on +return; PSGI application snapshots are closed and replenished from the +authoritative template after the response completes. + ## Combining Options Options can be combined for powerful one-liners: diff --git a/docs/reference/configure.md b/docs/reference/configure.md index 9cfc886f7..eb6b0170d 100644 --- a/docs/reference/configure.md +++ b/docs/reference/configure.md @@ -26,13 +26,16 @@ module reports the shipped runtime capabilities directly: | `usethreads` | `define` | The supported Perl thread API is enabled. | | `usemultiplicity` | `define` | Independent `PerlRuntime` instances are supported in one JVM. | -Platform Java threads are the stable default. The experimental virtual-thread -executor is selected process-wide with `JPERL_THREAD_MODE=virtual` or the JVM -property `-Djperl.thread.mode=virtual`; `platform` selects the default -explicitly. Unknown values are rejected. See +Java 24 virtual threads are the launcher default. The platform executor remains +available process-wide with `JPERL_THREAD_MODE=platform` or the JVM property +`-Djperl.thread.mode=platform`. Unknown values are rejected. See [CLI Options](cli-options.md#thread-execution-mode) and the [feature matrix](feature-matrix.md#concurrency-and-perl-threads). +Runtime pooling is independently opt-in. `JPERL_RUNTIME_POOL_SIZE=N` (or +`-Djperl.runtime.pool.size=N`) enables N prepared PSGI application snapshots; +the default `0` retains the single-runtime handler. + ## Options ### Help and Information diff --git a/docs/reference/feature-matrix.md b/docs/reference/feature-matrix.md index e4ea9883a..758aa4e3f 100644 --- a/docs/reference/feature-matrix.md +++ b/docs/reference/feature-matrix.md @@ -851,8 +851,8 @@ ithreads on both the JVM compiler and bytecode interpreter backends. | Identity and state | ✅ | `self`, `tid`, `list`, equality, running/joinable/detached checks, errors, nested threads, and child-only `threads->exit` are supported. | | `threads::shared` | 🟡 | `share`, `is_shared`, `shared_clone`, and `:shared` support scalar/array/hash graphs. Blessed aggregate roots use runtime-local class views over common backing; tied scalars clone callback state and tied arrays/hashes convert to native shared storage when shared. | | Locks and conditions | ✅ | Recursive lexical `lock`, `cond_wait`, absolute `cond_timedwait`, `cond_signal`, and `cond_broadcast` are supported. | -| Platform threads | ✅ | Stable default. | -| Virtual threads | 🟡 | Experimental process-wide opt-in; semantic parity is measured, but no performance benefit or complete native-I/O diagnostic clearance on Java 24 is claimed. | +| Platform threads | ✅ | Explicit compatibility mode and automatic fallback for a nonzero stack-size request. | +| Virtual threads | ✅ | Java 24 launcher default; snapshot, lifecycle, shared-storage, native-callback, DBI, and Test2 gates retain platform parity. | The clone-versus-share rule is important: ordinary references are cloned with aliasing and cycles preserved inside the child graph, but they are not the same @@ -864,12 +864,12 @@ storage as their parent counterparts. Values explicitly shared through | Limitation | Effect | |---|---| | Thread signals | `threads->kill` targets live attached children and resolves the handler inside the child runtime. Completed and detached targets are not signalable. | -| Effective stack sizing | Platform-backed children honor supported `stack_size` create/import requests. Virtual threads reject nonzero stack sizes because their stacks are JVM-managed. | +| Effective stack sizing | Platform-backed children honor supported `stack_size` create/import requests. A nonzero request under the default virtual policy transparently selects a platform child. | | Additional introspection | `threads->object` and creation-context `wantarray` are implemented. CLI shutdown reports running and finished unjoined threads; detached children are silent. | | Nested shared-object proxy identity | Root blessed/tied policies are implemented. Exact fresh proxy identity for nested references fetched from shared aggregates, and one global `DESTROY` owner across those views, remain follow-up work. | | Native resources and callbacks | File, socket, process, native-descriptor, scalar, layered, duplicated, borrowed, directory, and standard handles have explicit inheritance policies. Net::SSLeay handles remain runtime-owned and stored callbacks bind their registering runtime. | | Upstream suite coverage | Core thread/lvalue coverage includes `op/index_thr.t` 415/415 and `op/substr_thr.t` 400/400 on both backends. `class/threads.t`, Storable's thread test, `threads-dirh.t`, Test2's default thread IPC acceptance, and `user_prop_race_thr.t` complete. Lexical `re 'debug'` is runtime-owned; remaining regex-language gaps are tracked against direct companion tests. | -| PSGI | Availability of ithreads does not make one captured PSGI application runtime concurrently callable. `Plack::Handler::Netty` advertises `psgi.multithread => \0`. | +| PSGI | The default single-runtime handler advertises `psgi.multithread => \0`. A bounded opt-in pool gives every concurrent request an independent app snapshot and advertises `\1`; pool size defaults to zero. | The complete design and measured validation record is in [Concurrency and runtime isolation](../../dev/design/concurrency.md). diff --git a/examples/http_server_plack/PERFORMANCE.md b/examples/http_server_plack/PERFORMANCE.md index b18c93fd3..12860cce8 100644 --- a/examples/http_server_plack/PERFORMANCE.md +++ b/examples/http_server_plack/PERFORMANCE.md @@ -51,9 +51,8 @@ Tests streaming response path with responder callbacks. ### Performance Characteristics -- **Single application runtime**: This handler deliberately avoids concurrent - callbacks into one captured PSGI app. PerlOnJava itself supports explicit, - isolated ithreads. +- **Isolated application runtimes**: The default avoids concurrent callbacks + into one captured PSGI app. The opt-in pool checks out independent snapshots. - **Async I/O**: Handles high concurrency efficiently via Netty's NIO - **Memory Efficient**: No buffering of responses, constant memory usage - **CPU Bound**: Performance limited by single-thread CPU usage, not I/O @@ -79,9 +78,9 @@ Tests streaming response path with responder callbacks. - Database queries, API calls, file I/O all benefit from async model 2. **CPU-Bound Apps**: Consider implications - - Heavy computation blocks other requests in this single-runtime handler - - Applications may use explicit ithreads for isolated work, but the handler - still advertises `psgi.multithread => \0` + - Heavy computation blocks other requests in the default single-runtime handler + - Set `JPERL_RUNTIME_POOL_SIZE=N` to use N isolated application snapshots; + pooled requests advertise `psgi.multithread => \1` - Solution: Offload to background workers 3. **High-Traffic Sites**: Run multiple instances @@ -101,6 +100,6 @@ Tests streaming response path with responder callbacks. ✅ **Scalable**: Handles high concurrency efficiently ✅ **Memory efficient**: Constant memory usage, no leaks -The single-runtime limitation belongs to this handler design, not to the -availability of Perl ithreads. Horizontal scaling remains the recommended way -to run multiple PSGI application runtimes concurrently. +The single-runtime mode remains the safe default. The bounded runtime pool is +an explicit in-process scaling option; horizontal scaling remains appropriate +when process isolation is required. diff --git a/examples/http_server_plack/README.md b/examples/http_server_plack/README.md index 808633983..7d47e9065 100644 --- a/examples/http_server_plack/README.md +++ b/examples/http_server_plack/README.md @@ -113,7 +113,7 @@ The handler provides all standard PSGI v1.1 environment keys: - `CONTENT_LENGTH`, `CONTENT_TYPE` - `HTTP_*` headers (normalized to uppercase with underscores) - `psgi.version`, `psgi.url_scheme`, `psgi.input`, `psgi.errors` -- `psgi.multithread` (false), `psgi.multiprocess` (false) +- `psgi.multithread` (false by default; true with a runtime pool), `psgi.multiprocess` (false) - `psgi.run_once` (false), `psgi.nonblocking` (true), `psgi.streaming` (true) ## Configuration Options @@ -193,15 +193,16 @@ connections without concurrent callbacks into the captured PSGI application: ✅ **Handles:** Thousands of concurrent connections efficiently ⚠️ **Limitation:** CPU-bound request handlers may block other requests -PerlOnJava supports isolated Perl ithreads, but that capability does not make a -single captured PSGI runtime concurrently callable. The handler therefore -advertises `psgi.multithread => \0`. Applications may explicitly create -ithreads for isolated work, subject to the documented thread limitations. +PerlOnJava supports isolated Perl ithreads, but that capability alone does not +make a single captured PSGI runtime concurrently callable. The default handler +therefore advertises `psgi.multithread => \0`. Set +`JPERL_RUNTIME_POOL_SIZE=N` to prebuild N independent application snapshots; +pooled requests then advertise `psgi.multithread => \1`. ## Limitations -- **Single application runtime** - CPU-intensive handlers block other requests; - the handler does not provide a pool of concurrently callable app runtimes +- **Pooling is explicit** - The correctness-first default is one application + runtime. Size the opt-in pool for the application's memory and latency needs. ## Performance diff --git a/examples/threads/README.md b/examples/threads/README.md index 3c465994c..d7e23bdc2 100644 --- a/examples/threads/README.md +++ b/examples/threads/README.md @@ -13,6 +13,10 @@ These examples cover the two fundamental PerlOnJava ithread models: index to distribute documents, keeps each worker's word-count hash local, and merges ordinary result graphs after `join`. It demonstrates the recommended pattern: share coordination, not bulk mutable data. +- [`dbi_parallel_queries.pl`](dbi_parallel_queries.pl) creates a small SQLite + database, lets three ithreads open and own independent DBI connections, and + returns ordinary aggregate data through `join`. Native DBI handles are never + inherited or returned across runtime boundaries. Run either example from the repository root: @@ -20,6 +24,7 @@ Run either example from the repository root: ./jperl examples/threads/isolated_create_join.pl ./jperl examples/threads/shared_lock_condition.pl ./jperl examples/threads/dynamic_map_reduce.pl +./jperl examples/threads/dbi_parallel_queries.pl ``` The same source runs on standard threaded Perl: @@ -28,19 +33,22 @@ The same source runs on standard threaded Perl: perl examples/threads/isolated_create_join.pl perl examples/threads/shared_lock_condition.pl perl examples/threads/dynamic_map_reduce.pl +perl examples/threads/dbi_parallel_queries.pl ``` -PerlOnJava supports platform threads by default. Virtual threads are an -experimental process-wide execution mode; selecting them does not change Perl -snapshot or shared-storage semantics: +PerlOnJava uses Java 24 virtual threads by default. Platform threads remain a +process-wide compatibility mode; selecting them does not change Perl snapshot +or shared-storage semantics: ```bash -JPERL_OPTS=-Djperl.thread.mode=virtual \ +JPERL_THREAD_MODE=platform \ ./jperl examples/threads/isolated_create_join.pl ``` -Live attached children support targeted thread signals. Platform-backed -ithreads accept an effective Java stack-size request; virtual mode rejects a -nonzero request because virtual-thread stacks are JVM-managed. Sharing tied or -blessed values remains outside the supported tranche. A captured PSGI runtime -is also not made concurrently callable merely by enabling ithreads. +Live attached children support targeted thread signals. A nonzero stack-size +request automatically selects a platform-backed child because virtual-thread +stacks are JVM-managed. Shared blessed aggregates use runtime-local proxy +views over common backing; the advanced edge cases and tie-order rules are +documented in the feature matrix. A captured PSGI runtime is not made +concurrently callable merely by enabling ithreads; use the opt-in PSGI runtime +pool for concurrent request execution. diff --git a/examples/threads/dbi_parallel_queries.pl b/examples/threads/dbi_parallel_queries.pl new file mode 100644 index 000000000..f900fef9d --- /dev/null +++ b/examples/threads/dbi_parallel_queries.pl @@ -0,0 +1,50 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use DBI; +use File::Temp qw(tempfile); +use threads; + +my ($temporary, $database) = tempfile(SUFFIX => '.sqlite'); +close $temporary; +my $dsn = "dbi:SQLite:dbname=$database"; + +my $setup = DBI->connect($dsn, '', '', { + RaiseError => 1, + PrintError => 0, +}); +$setup->do('create table measurements (bucket integer, value integer)'); +my $insert = $setup->prepare('insert into measurements values (?, ?)'); +$insert->execute($_ % 3, $_) for 1 .. 12; +$setup->disconnect; + +sub query_bucket { + my ($child_dsn, $bucket) = @_; + # DBI handles are native runtime resources. Create and destroy them in the + # owning ithread; only ordinary Perl data crosses join(). + my $dbh = DBI->connect($child_dsn, '', '', { + RaiseError => 1, + PrintError => 0, + }); + my ($count, $sum) = $dbh->selectrow_array( + 'select count(*), sum(value) from measurements where bucket = ?', + undef, + $bucket, + ); + $dbh->disconnect; + return { bucket => $bucket, count => $count, sum => $sum }; +} + +my @workers = map { threads->create(\&query_bucket, $dsn, $_) } 0 .. 2; +my @results = sort { $a->{bucket} <=> $b->{bucket} } + map { $_->join } @workers; + +die "unexpected row counts\n" + unless join(',', map { $_->{count} } @results) eq '4,4,4'; +die "unexpected aggregate\n" + unless join(',', map { $_->{sum} } @results) eq '30,22,26'; +die "temporary database could not be removed\n" + unless unlink($database) || !-e $database; + +print "three child-owned connections returned sums 30,22,26\n"; diff --git a/jperl b/jperl index 036955ba1..1fb5449f1 100755 --- a/jperl +++ b/jperl @@ -15,6 +15,13 @@ JPERL_PATH="$(readlink -f "${BASH_SOURCE[0]}" 2>/dev/null || echo "$SCRIPT_DIR/j # Export environment variable for PerlOnJava to use as $^X export PERLONJAVA_EXECUTABLE="$JPERL_PATH" +# Java 24 virtual threads are the default Perl ithread carrier. Preserve an +# explicit environment selection; the equivalent JVM property in JPERL_OPTS +# still takes precedence inside PerlThreadExecutionPolicy. +if [ -z "${JPERL_THREAD_MODE+x}" ]; then + export JPERL_THREAD_MODE=virtual +fi + # Check development environment first (target directory). During Maven's test # phase the packaged JAR does not exist yet, so use compiled classes plus the # runtime dependency classpath generated by maven-dependency-plugin. diff --git a/jperl.bat b/jperl.bat index 132ad7f9d..6ea793e9d 100755 --- a/jperl.bat +++ b/jperl.bat @@ -13,6 +13,10 @@ set JPERL_PATH=%~f0 rem Set environment variable for PerlOnJava to use as $^X set PERLONJAVA_EXECUTABLE=%JPERL_PATH% +rem Java 24 virtual threads are the default Perl ithread carrier. Preserve an +rem explicit caller selection (JPERL_THREAD_MODE=platform remains supported). +if not defined JPERL_THREAD_MODE set JPERL_THREAD_MODE=virtual + rem Determine JVM options based on Java version rem --enable-native-access=ALL-UNNAMED: Required by FFM (Foreign Function & Memory) API rem for native system calls (file operations, process management). diff --git a/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java b/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java index 61ef82287..8e3bfd5bd 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java +++ b/src/main/java/org/perlonjava/backend/bytecode/CompileOperator.java @@ -1,6 +1,7 @@ package org.perlonjava.backend.bytecode; import org.perlonjava.frontend.astnode.*; +import org.perlonjava.frontend.analysis.RegexLiteralAnalyzer; import org.perlonjava.runtime.operators.ScalarGlobOperator; import org.perlonjava.runtime.regex.RuntimeRegex; import org.perlonjava.runtime.runtimetypes.*; @@ -1065,15 +1066,16 @@ public static void visitOperator(BytecodeCompiler bytecodeCompiler, OperatorNode if (operand.elements.size() < 2) { bytecodeCompiler.throwCompilerException("quoteRegex requires pattern and flags"); } - if (operand.elements.get(0) instanceof StringNode literalPattern + String literalPattern = RegexLiteralAnalyzer.constantString(operand.elements.get(0)); + if (literalPattern != null && operand.elements.get(1) instanceof StringNode literalFlags - && !RuntimeRegex.requiresRuntimeUnicodePropertyResolution(literalPattern.value)) { + && !RuntimeRegex.requiresRuntimeUnicodePropertyResolution(literalPattern)) { String modifiers = literalFlags.value; if (unicodeStringsImplicitUFlag(bytecodeCompiler) != 0 && !modifiers.contains("u")) { modifiers += "u"; } - RuntimeRegex.validateLiteralSyntax(literalPattern.value, modifiers); + RuntimeRegex.validateLiteralSyntax(literalPattern, modifiers); } boolean hasOModifier = false; Node flagsNode = operand.elements.get(1); diff --git a/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java b/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java index dd294f088..f7dca24ef 100644 --- a/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java +++ b/src/main/java/org/perlonjava/backend/bytecode/InlineOpcodeHandler.java @@ -566,8 +566,7 @@ public static int executeArraySet(int[] bytecode, int pc, RuntimeBase[] register RuntimeScalar val = (valueBase instanceof RuntimeScalar) ? (RuntimeScalar) valueBase : valueBase.scalar(); RuntimeScalar element = arr.get(idx); - element.set(val); - registers[rd] = element; + registers[rd] = element.set(val); return pc; } @@ -734,8 +733,7 @@ public static int executeHashSet(int[] bytecode, int pc, RuntimeBase[] registers RuntimeBase valBase = registers[valueReg]; RuntimeScalar val = (valBase instanceof RuntimeScalar) ? (RuntimeScalar) valBase : valBase.scalar(); RuntimeScalar target = hash.get(key); - val.addToScalar(target); - registers[rd] = target; + registers[rd] = val.addToScalar(target); return pc; } diff --git a/src/main/java/org/perlonjava/backend/jvm/EmitRegex.java b/src/main/java/org/perlonjava/backend/jvm/EmitRegex.java index 599a1e008..60bf5c6fb 100644 --- a/src/main/java/org/perlonjava/backend/jvm/EmitRegex.java +++ b/src/main/java/org/perlonjava/backend/jvm/EmitRegex.java @@ -2,6 +2,7 @@ import org.objectweb.asm.Opcodes; import org.perlonjava.frontend.analysis.EmitterVisitor; +import org.perlonjava.frontend.analysis.RegexLiteralAnalyzer; import org.perlonjava.frontend.astnode.*; import org.perlonjava.runtime.perlmodule.Strict; import org.perlonjava.runtime.regex.RuntimeRegex; @@ -297,16 +298,16 @@ static void handleQuoteRegex(EmitterVisitor emitterVisitor, OperatorNode node) { /** Validate non-interpolated qr// at CV compilation, as Perl does. */ private static void validateLiteralRegex(EmitterVisitor emitterVisitor, ListNode operand) { if (operand.elements.size() < 2 - || !(operand.elements.get(0) instanceof StringNode pattern) - || !(operand.elements.get(1) instanceof StringNode flags) - || RuntimeRegex.requiresRuntimeUnicodePropertyResolution(pattern.value)) { + || !(operand.elements.get(1) instanceof StringNode flags)) { return; } + String pattern = RegexLiteralAnalyzer.constantString(operand.elements.get(0)); + if (pattern == null || RuntimeRegex.requiresRuntimeUnicodePropertyResolution(pattern)) return; String modifiers = flags.value; if (unicodeStringsEnabled(emitterVisitor) && !modifiers.contains("u")) { modifiers += "u"; } - RuntimeRegex.validateLiteralSyntax(pattern.value, modifiers); + RuntimeRegex.validateLiteralSyntax(pattern, modifiers); } /** diff --git a/src/main/java/org/perlonjava/frontend/analysis/RegexLiteralAnalyzer.java b/src/main/java/org/perlonjava/frontend/analysis/RegexLiteralAnalyzer.java new file mode 100644 index 000000000..b0cce40e0 --- /dev/null +++ b/src/main/java/org/perlonjava/frontend/analysis/RegexLiteralAnalyzer.java @@ -0,0 +1,39 @@ +package org.perlonjava.frontend.analysis; + +import org.perlonjava.frontend.astnode.BinaryOperatorNode; +import org.perlonjava.frontend.astnode.ListNode; +import org.perlonjava.frontend.astnode.Node; +import org.perlonjava.frontend.astnode.OperatorNode; +import org.perlonjava.frontend.astnode.StringNode; +import org.perlonjava.runtime.operators.StringOperators; +import org.perlonjava.runtime.runtimetypes.RuntimeScalar; + +/** Compile-time evaluation of regex strings made entirely from literals. */ +public final class RegexLiteralAnalyzer { + private RegexLiteralAnalyzer() {} + + /** + * Return the exact constant string, or {@code null} when runtime data is + * required. In particular, {@code \Q...\E} is parsed as concatenation with + * a quotemeta operator, but remains a compile-time regex literal in Perl. + */ + public static String constantString(Node node) { + if (node instanceof StringNode string) return string.value; + if (node instanceof BinaryOperatorNode binary && ".".equals(binary.operator)) { + String left = constantString(binary.left); + String right = constantString(binary.right); + return left == null || right == null ? null : left + right; + } + if (node instanceof OperatorNode operator && "quotemeta".equals(operator.operator)) { + Node operand = operator.operand; + if (operand instanceof ListNode list) { + if (list.elements.size() != 1) return null; + operand = list.elements.getFirst(); + } + String value = constantString(operand); + return value == null ? null + : StringOperators.quotemeta(new RuntimeScalar(value)).toString(); + } + return null; + } +} diff --git a/src/main/java/org/perlonjava/runtime/perlmodule/PlackHandlerNetty.java b/src/main/java/org/perlonjava/runtime/perlmodule/PlackHandlerNetty.java index 3754aaaab..6ca077716 100644 --- a/src/main/java/org/perlonjava/runtime/perlmodule/PlackHandlerNetty.java +++ b/src/main/java/org/perlonjava/runtime/perlmodule/PlackHandlerNetty.java @@ -14,6 +14,10 @@ import org.perlonjava.runtime.runtimetypes.*; import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import java.util.WeakHashMap; /** * PlackHandlerNetty - PSGI server implementation using Netty. @@ -25,14 +29,14 @@ * Key Features: * - Full PSGI v1.1 environment hash construction * - Synchronous array response support (Phase 1) - * - Single-threaded event loop (PerlOnJava thread-safety requirement) + * - Runtime-pooled concurrent request handling when explicitly configured * - Error handling * - HTTP/1.1 with keep-alive support * * Thread Safety: - * PerlOnJava is currently NOT thread-safe. This server uses a single-threaded - * event loop (NioEventLoopGroup(1)) to avoid race conditions. Multiple concurrent - * connections are handled via Netty's async I/O on one thread. + * The default pool size is zero, retaining one captured runtime and one worker. + * An explicitly configured pool checks out an independent runtime snapshot for + * each request; a runtime is never entered concurrently. * * Usage: *
@@ -346,6 +350,9 @@ private static void startNettyServer(int port, String host, RuntimeScalar psgiAp
String sslCa, String[] sslProtocols, String sslCiphers)
throws InterruptedException {
PerlRuntime runtime = PerlRuntime.current();
+ int poolSize = PerlRuntimePool.configuredSize();
+ PsgiRuntimePool runtimePool = poolSize == 0
+ ? null : new PsgiRuntimePool(runtime, psgiApp, poolSize);
// Build SSL context if enabled
io.netty.handler.ssl.SslContext sslContext = null;
@@ -363,10 +370,11 @@ private static void startNettyServer(int port, String host, RuntimeScalar psgiAp
final io.netty.handler.ssl.SslContext finalSslContext = sslContext;
- // Single-threaded event loop to avoid PerlOnJava thread-safety issues
- // This still handles many concurrent connections via async I/O
+ // Pooling is opt-in. Without it, retain the historical single-runtime,
+ // single-worker boundary. With it, every event-loop worker checks out
+ // a distinct interpreter snapshot.
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
- EventLoopGroup workerGroup = new NioEventLoopGroup(1);
+ EventLoopGroup workerGroup = new NioEventLoopGroup(Math.max(1, poolSize));
// Add shutdown hook for graceful shutdown on SIGTERM/SIGINT
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
@@ -400,7 +408,8 @@ protected void initChannel(SocketChannel ch) {
pipeline.addLast(new HttpObjectAggregator(maxRequestSize));
// PSGI request handler
- pipeline.addLast(new PSGIRequestHandler(psgiApp, host, port, keepAlive, runtime));
+ pipeline.addLast(new PSGIRequestHandler(
+ psgiApp, host, port, keepAlive, runtime, runtimePool));
}
})
.option(ChannelOption.SO_BACKLOG, backlog)
@@ -418,6 +427,7 @@ protected void initChannel(SocketChannel ch) {
e.printStackTrace(System.err);
throw e;
} finally {
+ if (runtimePool != null) runtimePool.close();
// Shutdown event loops if not already shutdown
if (!bossGroup.isShutdown()) {
bossGroup.shutdownGracefully();
@@ -428,6 +438,61 @@ protected void initChannel(SocketChannel ch) {
}
}
+ /** Prepared request snapshots plus their app root, bounded by the configured pool size. */
+ static final class PsgiRuntimePool implements AutoCloseable {
+ private final PerlRuntime template;
+ private final RuntimeScalar templateApp;
+ private final Map apps =
+ Collections.synchronizedMap(new WeakHashMap<>());
+ private final PerlRuntimePool pool;
+
+ PsgiRuntimePool(PerlRuntime template, RuntimeScalar templateApp, int size) {
+ this.template = template;
+ this.templateApp = templateApp;
+ this.pool = new PerlRuntimePool(size, this::createSnapshot, this::replaceSnapshot);
+ }
+
+ private PerlRuntime createSnapshot() {
+ PerlRuntime.RootSnapshot snapshot = template.snapshotCloneWithRoots(
+ java.util.List.of(templateApp));
+ RuntimeScalar app = (RuntimeScalar) snapshot.roots().getFirst();
+ apps.put(snapshot.runtime(), app);
+ return snapshot.runtime();
+ }
+
+ private PerlRuntime replaceSnapshot(PerlRuntime used) {
+ apps.remove(used);
+ used.close();
+ return createSnapshot();
+ }
+
+ RequestLease checkout() throws InterruptedException {
+ PerlRuntimePool.Lease lease = pool.checkout(Duration.ofSeconds(30));
+ RuntimeScalar app = apps.get(lease.runtime());
+ if (app == null) {
+ lease.close();
+ throw new IllegalStateException("PSGI runtime has no cloned application root");
+ }
+ return new RequestLease(lease, app);
+ }
+
+ int size() {
+ return pool.capacity();
+ }
+
+ @Override
+ public void close() {
+ pool.close();
+ apps.clear();
+ }
+
+ record RequestLease(PerlRuntimePool.Lease lease, RuntimeScalar app)
+ implements AutoCloseable {
+ PerlRuntime runtime() { return lease.runtime(); }
+ @Override public void close() { lease.close(); }
+ }
+ }
+
/**
* PSGIRequestHandler - Netty channel handler that processes HTTP requests via PSGI.
*
@@ -444,19 +509,32 @@ static class PSGIRequestHandler extends SimpleChannelInboundHandler cloned = new RuntimeGraphCloner(
+ thread.childRuntime(), PerlRuntime.current()).cloneRoots(values.elements);
+ if (thread.context() == RuntimeContextType.VOID) {
+ return RuntimeScalarCache.scalarUndef.getList();
+ }
+ if (ctx == RuntimeContextType.VOID) return new RuntimeList();
+ if (ctx == RuntimeContextType.SCALAR) {
+ return cloned.isEmpty() ? RuntimeScalarCache.scalarUndef.getList()
+ : cloned.getLast().scalar().getList();
+ }
+ return new RuntimeList(cloned.toArray(RuntimeBase[]::new));
+ } finally {
+ thread.releaseTerminalResources();
}
- if (!(completion.value() instanceof RuntimeArray values)) return new RuntimeList();
- List cloned = new RuntimeGraphCloner(
- thread.childRuntime(), PerlRuntime.current()).cloneRoots(values.elements);
- if (thread.context() == RuntimeContextType.VOID) {
- return RuntimeScalarCache.scalarUndef.getList();
- }
- if (ctx == RuntimeContextType.VOID) return new RuntimeList();
- if (ctx == RuntimeContextType.SCALAR) {
- return cloned.isEmpty() ? RuntimeScalarCache.scalarUndef.getList()
- : cloned.getLast().scalar().getList();
- }
- return new RuntimeList(cloned.toArray(RuntimeBase[]::new));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Thread join interrupted", e);
@@ -264,7 +268,8 @@ public static RuntimeList _set_thread_exit_only(RuntimeArray args, int ctx) {
boolean value = args.get(1).getBoolean();
if (invocant.type == RuntimeScalarType.HASHREFERENCE) {
PerlThreadControlBlock thread = findKnownThread(threadHash(args));
- if (thread != null) thread.childRuntime().setPerlThreadExitOnly(value);
+ PerlRuntime child = thread == null ? null : thread.childRuntime();
+ if (child != null) child.setPerlThreadExitOnly(value);
} else {
PerlRuntime.current().setPerlThreadExitOnly(value);
}
diff --git a/src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java b/src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java
index b2991719e..4b8d52c07 100644
--- a/src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java
+++ b/src/main/java/org/perlonjava/runtime/regex/RegexPreprocessor.java
@@ -1570,6 +1570,15 @@ private static int handleParentheses(String s, int offset, int length, StringBui
}
String verbName = s.substring(offset + 2, verbNameEnd);
+ // Perl's (*FAIL) / (*F) is a zero-width assertion that can never
+ // succeed. Java's empty negative lookahead has exactly that
+ // declarative behavior and preserves surrounding backtracking.
+ if ((verbName.equals("FAIL") || verbName.equals("F"))
+ && verbNameEnd < length && s.codePointAt(verbNameEnd) == ')') {
+ sb.append("(?!)");
+ return verbNameEnd;
+ }
+
// Check for alpha assertion aliases (Perl 5.28+)
String replacement = switch (verbName) {
case "pla", "positive_lookahead" -> "(?=";
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java b/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java
index f0ac24cf7..ccdc8c8f5 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/DestroyDispatch.java
@@ -180,6 +180,11 @@ public static void invalidateCache() {
public static void callDestroy(RuntimeBase referent) {
// refCount is already MIN_VALUE (set by caller)
+ // A shared aggregate may have runtime-local fetch views in another
+ // ithread. Canonical deletion waits for those views; the runtime that
+ // releases the last view performs the one Perl DESTROY callback.
+ if (referent.deferSharedDestroy()) return;
+
// Phase 3 (refcount_alignment_plan.md): Re-entry guard.
// If this object is already inside its own DESTROY body, a transient
// decrement-to-0 (local temp release, deferred MortalList flush,
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/NextMethod.java b/src/main/java/org/perlonjava/runtime/runtimetypes/NextMethod.java
index fb79e69ae..ae6ce5f37 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/NextMethod.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/NextMethod.java
@@ -413,6 +413,18 @@ static RuntimeScalar superMethod(RuntimeScalar currentSub, String methodName, St
if (mrp != null && !mrp.isEmpty()) {
packageName = mrp;
}
+ } else {
+ // A lazily materialized ithread CODE can transiently lack the
+ // generated implementation's __SUB__ field even though apply()
+ // has already established its RuntimeCode frame. SUPER is lexical:
+ // recover that package from the active named method instead of
+ // restarting at the most-derived invocant and resolving the same
+ // override recursively.
+ CallerMethod active = resolveCallerMethodFromActiveCodeStack();
+ if (active != null && active.callerPackage() != null
+ && !active.callerPackage().isEmpty()) {
+ packageName = active.callerPackage();
+ }
}
method = InheritanceResolver.findMethodInHierarchy(
methodName.substring(7), // method name without SUPER:: prefix
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntime.java b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntime.java
index d36c7b23f..076161c4d 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntime.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntime.java
@@ -394,6 +394,15 @@ public PerlRuntime snapshotClone() {
return snapshotCloneInternal(new PerlThreadRegistry(), 0).runtime();
}
+ /** Snapshot this runtime and clone additional non-global roots through the same graph map. */
+ public RootSnapshot snapshotCloneWithRoots(java.util.List extends RuntimeBase> roots) {
+ Objects.requireNonNull(roots, "roots");
+ ThreadSnapshot snapshot = snapshotCloneInternal(new PerlThreadRegistry(), 0);
+ return new RootSnapshot(snapshot.runtime(), snapshot.cloner().cloneRoots(roots));
+ }
+
+ public record RootSnapshot(PerlRuntime runtime, java.util.List roots) {}
+
record ThreadSnapshot(PerlRuntime runtime, RuntimeGraphCloner cloner) {}
ThreadSnapshot snapshotCloneForThread(PerlThreadRegistry registry, long threadId) {
@@ -470,7 +479,15 @@ private void materializeLazyCodeDefinitions() {
&& !code.closedOverVariables.isEmpty();
if (!cloneHook && !fqn.startsWith("threads::") && !capturesLexicals) continue;
if (code.compilerSupplier != null) {
- code.compilerSupplier.get();
+ try {
+ code.compilerSupplier.get();
+ } catch (PerlCompilerException expectedAtUseSite) {
+ // Snapshot preflight must not turn an expected-invalid lazy
+ // helper into a require-time failure. The definition stays
+ // lazy and reports its compile error when user code invokes
+ // it. CLONE hooks are part of snapshot itself and must fail.
+ if (cloneHook) throw expectedAtUseSite;
+ }
}
}
}
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntimePool.java b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntimePool.java
new file mode 100644
index 000000000..a6bc20478
--- /dev/null
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlRuntimePool.java
@@ -0,0 +1,174 @@
+package org.perlonjava.runtime.runtimetypes;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.IdentityHashMap;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Supplier;
+
+/** A bounded, explicitly owned pool of independent {@link PerlRuntime}s. */
+public final class PerlRuntimePool implements AutoCloseable {
+ public static final String SIZE_PROPERTY = "jperl.runtime.pool.size";
+ public static final String SIZE_ENVIRONMENT = "JPERL_RUNTIME_POOL_SIZE";
+
+ @FunctionalInterface
+ public interface Recycler {
+ PerlRuntime recycle(PerlRuntime runtime) throws Exception;
+ }
+
+ private final int capacity;
+ private final Supplier factory;
+ private final Recycler recycler;
+ private final ArrayBlockingQueue available;
+ private final Set checkedOut =
+ Collections.newSetFromMap(new IdentityHashMap<>());
+ private final Object ownershipMonitor = new Object();
+ private final AtomicBoolean closed = new AtomicBoolean();
+
+ public PerlRuntimePool(int capacity) {
+ this(capacity, () -> new PerlRuntime().initialize(), runtime -> runtime.reset());
+ }
+
+ public PerlRuntimePool(int capacity, Supplier factory, Recycler recycler) {
+ if (capacity < 0) throw new IllegalArgumentException("Runtime pool size must not be negative");
+ this.capacity = capacity;
+ this.factory = Objects.requireNonNull(factory, "factory");
+ this.recycler = Objects.requireNonNull(recycler, "recycler");
+ this.available = capacity == 0 ? null : new ArrayBlockingQueue<>(capacity);
+ for (int i = 0; i < capacity; i++) {
+ available.add(newRuntime());
+ }
+ }
+
+ /** Resolve the process-wide pool size. The safe default is disabled. */
+ public static int configuredSize() {
+ return resolveSize(System.getProperty(SIZE_PROPERTY), System.getenv(SIZE_ENVIRONMENT));
+ }
+
+ static int resolveSize(String propertyValue, String environmentValue) {
+ String value = propertyValue != null ? propertyValue : environmentValue;
+ if (value == null || value.isBlank()) return 0;
+ final int size;
+ try {
+ size = Integer.parseInt(value.strip());
+ } catch (NumberFormatException invalid) {
+ throw new IllegalArgumentException(
+ "Invalid Perl runtime pool size '" + value + "'; expected a non-negative integer",
+ invalid);
+ }
+ if (size < 0) throw new IllegalArgumentException("Runtime pool size must not be negative");
+ return size;
+ }
+
+ public int capacity() {
+ return capacity;
+ }
+
+ public int availableCount() {
+ return capacity == 0 ? 0 : available.size();
+ }
+
+ /**
+ * Check out one runtime, waiting for the bounded deadline when pooling is enabled.
+ * A disabled pool returns a one-shot runtime whose lease closes it.
+ */
+ public Lease checkout(Duration timeout) throws InterruptedException {
+ Objects.requireNonNull(timeout, "timeout");
+ if (timeout.isNegative()) throw new IllegalArgumentException("Checkout timeout must not be negative");
+ if (closed.get()) throw new IllegalStateException("Perl runtime pool is closed");
+
+ PerlRuntime runtime;
+ boolean pooled = capacity != 0;
+ if (pooled) {
+ runtime = available.poll(timeout.toNanos(), TimeUnit.NANOSECONDS);
+ if (runtime == null) throw new IllegalStateException("Timed out waiting for a Perl runtime");
+ } else {
+ runtime = newRuntime();
+ }
+ synchronized (ownershipMonitor) {
+ if (closed.get()) {
+ runtime.close();
+ throw new IllegalStateException("Perl runtime pool is closed");
+ }
+ if (!checkedOut.add(runtime)) {
+ throw new IllegalStateException("Perl runtime was checked out twice");
+ }
+ }
+ return new Lease(this, runtime, pooled);
+ }
+
+ private PerlRuntime newRuntime() {
+ PerlRuntime runtime = Objects.requireNonNull(factory.get(), "runtime factory returned null");
+ if (runtime.isClosed()) throw new IllegalStateException("Runtime factory returned a closed runtime");
+ return runtime.isInitialized() ? runtime : runtime.initialize();
+ }
+
+ private void release(PerlRuntime runtime, boolean pooled) {
+ synchronized (ownershipMonitor) {
+ if (!checkedOut.remove(runtime)) {
+ throw new IllegalStateException("Perl runtime lease is not owned by this pool");
+ }
+ }
+ if (!pooled || closed.get()) {
+ runtime.close();
+ return;
+ }
+
+ PerlRuntime reusable = null;
+ try {
+ reusable = Objects.requireNonNull(recycler.recycle(runtime), "runtime recycler returned null");
+ if (reusable.isClosed()) throw new IllegalStateException("Runtime recycler returned a closed runtime");
+ } catch (Throwable resetFailure) {
+ runtime.close();
+ try {
+ reusable = newRuntime();
+ } catch (Throwable replacementFailure) {
+ resetFailure.addSuppressed(replacementFailure);
+ throw new IllegalStateException("Failed to recycle and replace a Perl runtime", resetFailure);
+ }
+ }
+
+ if (closed.get() || !available.offer(reusable)) {
+ reusable.close();
+ if (!closed.get()) throw new IllegalStateException("Perl runtime pool overflow");
+ }
+ }
+
+ @Override
+ public void close() {
+ if (!closed.compareAndSet(false, true)) return;
+ if (available != null) {
+ PerlRuntime runtime;
+ while ((runtime = available.poll()) != null) runtime.close();
+ }
+ // Checked-out runtimes are closed by their lease. Closing a pool never
+ // races an in-flight request by forcibly resetting its interpreter.
+ }
+
+ public static final class Lease implements AutoCloseable {
+ private final PerlRuntimePool pool;
+ private final PerlRuntime runtime;
+ private final boolean pooled;
+ private final AtomicBoolean returned = new AtomicBoolean();
+
+ private Lease(PerlRuntimePool pool, PerlRuntime runtime, boolean pooled) {
+ this.pool = pool;
+ this.runtime = runtime;
+ this.pooled = pooled;
+ }
+
+ public PerlRuntime runtime() {
+ if (returned.get()) throw new IllegalStateException("Perl runtime lease is closed");
+ return runtime;
+ }
+
+ @Override
+ public void close() {
+ if (returned.compareAndSet(false, true)) pool.release(runtime, pooled);
+ }
+ }
+}
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlThreadControlBlock.java b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlThreadControlBlock.java
index 8e5a0aacc..05b40dea6 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlThreadControlBlock.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlThreadControlBlock.java
@@ -22,8 +22,8 @@ public record Completion(RuntimeBase value, Throwable error) {}
private final long id;
private final long parentId;
private final PerlThreadRegistry registry;
- private final PerlRuntime childRuntime;
- private final EntryPoint entryPoint;
+ private volatile PerlRuntime childRuntime;
+ private volatile EntryPoint entryPoint;
private final int context;
private final long stackSize;
private final RuntimeIO parentErrorOutput;
@@ -103,7 +103,9 @@ public static PerlThreadControlBlock create(
public synchronized PerlThreadControlBlock start() {
if (state != State.NEW) throw new IllegalStateException("Thread already started");
state = State.RUNNING;
- platformThread = PerlThreadExecutionPolicy.configured().unstarted(id, stackSize, this::run);
+ platformThread = PerlThreadExecutionPolicy.configured()
+ .effectiveForStackSize(stackSize)
+ .unstarted(id, stackSize, this::run);
platformThread.start();
return this;
}
@@ -114,7 +116,9 @@ private void run() {
RuntimeBase value = null;
Throwable failure = null;
try {
- value = entryPoint.run(childRuntime);
+ EntryPoint work = entryPoint;
+ if (work == null) throw new IllegalStateException("Thread entry point was released early");
+ value = work.run(childRuntime);
} catch (Throwable thrown) {
PerlThreadExitException exit = findThreadExit(thrown);
if (exit != null) value = exit.values();
@@ -138,6 +142,7 @@ private void run() {
if (detached) {
reportAbnormalTermination();
registry.remove(this);
+ releaseTerminalResources();
}
}
}
@@ -177,6 +182,7 @@ public synchronized void detach() {
state = State.DETACHED;
reportAbnormalTermination();
registry.remove(this);
+ releaseTerminalResources();
}
}
@@ -197,11 +203,31 @@ public synchronized void detach() {
/** Deliver a Perl signal in the target runtime at its next safe point. */
public void signal(String signal) {
if (!isRunning()) return;
- PerlSignalQueue.enqueue(childRuntime.signalState, signal);
+ PerlRuntime runtime = childRuntime;
+ if (runtime == null) return;
+ PerlSignalQueue.enqueue(runtime.signalState, signal);
Thread javaThread = platformThread;
if (javaThread != null) javaThread.interrupt();
}
+ /**
+ * Drop the completed child's package graph after its return values have
+ * been cloned into the joining runtime. Terminal thread-object aliases
+ * retain this control block's state and error, but must not retain an
+ * entire interpreter snapshot indefinitely.
+ */
+ public void releaseTerminalResources() {
+ PerlRuntime runtime;
+ synchronized (this) {
+ if (state != State.JOINED && state != State.DETACHED) return;
+ result = null;
+ entryPoint = null;
+ runtime = childRuntime;
+ childRuntime = null;
+ }
+ if (runtime != null) runtime.close();
+ }
+
private void reportAbnormalTermination() {
Throwable failure = error;
if (failure == null || !abnormalTerminationReported.compareAndSet(false, true)) return;
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlThreadExecutionPolicy.java b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlThreadExecutionPolicy.java
index 6fbe85709..8b65d777d 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/PerlThreadExecutionPolicy.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/PerlThreadExecutionPolicy.java
@@ -39,6 +39,15 @@ public Mode mode() {
return mode;
}
+ /** Select a platform carrier when Perl requests an explicit stack size. */
+ PerlThreadExecutionPolicy effectiveForStackSize(long stackSize) {
+ if (stackSize < 0) throw new IllegalArgumentException("Thread stack size must not be negative");
+ if (mode == Mode.VIRTUAL && stackSize != 0) {
+ return new PerlThreadExecutionPolicy(Mode.PLATFORM);
+ }
+ return this;
+ }
+
public Thread unstarted(long id, Runnable task) {
return unstarted(id, 0, task);
}
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java
index ad9169df5..f1f1fa6d7 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/ReachabilityWalker.java
@@ -111,6 +111,16 @@ public Set walk() {
for (Map.Entry e : GlobalVariable.globalCodeRefs.entrySet()) {
visitScalar(e.getValue(), todo);
}
+ // An executing anonymous closure is a live Perl root even when no
+ // package/global slot refers to its CODE value. Statement-boundary
+ // sweeps run inside RuntimeCode.apply(); follow that active closure's
+ // captures before deciding that a blessed referent is unreachable.
+ // Test2::AsyncSubtest exposes this with a weak hub back-reference: the
+ // child entry closure is the sole strong owner of $self while it runs.
+ for (RuntimeCode active : new ArrayList<>(
+ PerlRuntime.current().executionState().activeCodeStack)) {
+ addReachable(active, todo);
+ }
bfs(todo, /*walkCaptures=*/ true);
// Phase 2: seed remaining roots.
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java
index b8ed9bb61..fdb04ab6d 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeArray.java
@@ -943,7 +943,7 @@ public RuntimeScalar get(int index) {
return new RuntimeArrayProxyEntry(RuntimeArray.this, index);
}
- return element;
+ return SharedPerlStorage.fetchedElement(this, element);
}
/**
@@ -1002,7 +1002,7 @@ public RuntimeScalar get(RuntimeScalar value) {
return new RuntimeArrayProxyEntry(RuntimeArray.this, index);
}
- return element;
+ return SharedPerlStorage.fetchedElement(this, element);
}
/**
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java
index 814100afd..a96553627 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeBase.java
@@ -18,6 +18,66 @@ public abstract class RuntimeBase implements DynamicState, Iterable 0) return true;
+ return !lifecycle.destroyFired.compareAndSet(false, true);
+ }
// Index to the class that this reference belongs
public int blessId;
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java
index a974c773e..4edebd3d3 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeCode.java
@@ -428,6 +428,18 @@ public static RuntimeArray getOriginalArgsAt(int frame) {
return null;
}
+ /** True when this scalar is one of the current call's original @_ aliases. */
+ public static boolean isCurrentArgumentAlias(RuntimeScalar scalar) {
+ if (scalar == null) return false;
+ if (PerlRuntime.currentOrNull() == null) return false;
+ Deque> stack = pristineArgsStack();
+ if (stack.isEmpty()) return false;
+ for (RuntimeScalar argument : stack.peek()) {
+ if (argument == scalar) return true;
+ }
+ return false;
+ }
+
/**
* Return the pristine arguments belonging to a specific active code object.
* The formatted caller stack collapses compiler/interpreter wrapper pairs,
@@ -1011,6 +1023,23 @@ public static void inheritSelfReference(RuntimeScalar callbackRef, RuntimeScalar
}
}
+ /** Restore this CODE value's own __SUB__ reference after an ithread clone. */
+ void restoreClonedSelfReference(RuntimeScalar selfRef) {
+ __SUB__ = selfRef;
+ Object implementation = codeObject != null ? codeObject : subroutine;
+ if (implementation == null || implementation instanceof org.perlonjava.backend.bytecode.InterpretedCode) {
+ return;
+ }
+ try {
+ Field field = implementation.getClass().getDeclaredField("__SUB__");
+ field.set(implementation, selfRef);
+ } catch (NoSuchFieldException ignored) {
+ // Native/Java-backed implementations have no generated __SUB__ field.
+ } catch (ReflectiveOperationException e) {
+ throw new IllegalStateException("Unable to restore cloned __SUB__ reference", e);
+ }
+ }
+
/**
* Captured RuntimeScalar variables from the enclosing scope.
* Set by {@link #makeCodeObject} for closures that capture lexical variables.
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java
index 210ceddca..a80015d92 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeGraphCloner.java
@@ -277,6 +277,13 @@ private void copyCodeMetadata(RuntimeCode source, RuntimeCode target) {
target.isMapGrepBlock = source.isMapGrepBlock;
target.isEvalBlock = source.isEvalBlock;
target.isTryExpressionWrapper = source.isTryExpressionWrapper;
+ target.inheritsSelfReference = source.inheritsSelfReference;
+ target.explicitlyRenamed = source.explicitlyRenamed;
+ target.isConstantCv = source.isConstantCv;
+ target.stashInstallPackage = source.stashInstallPackage;
+ target.stashInstallSub = source.stashInstallSub;
+ target.hadStashRef = source.hadStashRef;
+ target.installedViaAnonGlobAssign = source.installedViaAnonGlobAssign;
target.cvStartFile = source.cvStartFile;
target.cvStartLine = source.cvStartLine;
target.deparseSourceText = source.deparseSourceText;
@@ -289,6 +296,10 @@ private void copyCodeMetadata(RuntimeCode source, RuntimeCode target) {
target.stateVariable = cloneScalarMap(source.stateVariable);
target.stateArray = cloneArrayMap(source.stateArray);
target.stateHash = cloneHashMap(source.stateHash);
+ if (source.__SUB__ != null) {
+ target.restoreClonedSelfReference(
+ (RuntimeScalar) cloneValue(source.__SUB__));
+ }
if (source.constantValue == null) {
target.constantValue = null;
} else {
@@ -303,12 +314,38 @@ private void copyCodeMetadata(RuntimeCode source, RuntimeCode target) {
for (Map.Entry entry : source.closedOverVariables.entrySet()) {
target.closedOverVariables.put(entry.getKey(), cloneValue(entry.getValue()));
}
- target.capturedScalars = target.closedOverVariables.values().stream()
- .filter(RuntimeScalar.class::isInstance).map(RuntimeScalar.class::cast)
- .toArray(RuntimeScalar[]::new);
- target.capturedAggregates = target.closedOverVariables.values().stream()
- .filter(value -> value instanceof RuntimeArray || value instanceof RuntimeHash)
- .toArray(RuntimeBase[]::new);
+ }
+
+ // capturedScalars/capturedAggregates are the authoritative ownership
+ // lists built by makeCodeObject()/CREATE_CLOSURE. closedOverVariables
+ // is diagnostic/name metadata and can legitimately omit executable
+ // captures, so rebuilding ownership from that map allowed a method
+ // return to DESTROY a still-live captured object in an ithread.
+ if (source.capturedScalars != null) {
+ target.capturedScalars = new RuntimeScalar[source.capturedScalars.length];
+ for (int i = 0; i < source.capturedScalars.length; i++) {
+ target.capturedScalars[i] =
+ (RuntimeScalar) cloneValue(source.capturedScalars[i]);
+ }
+ try (PerlRuntime.Binding ignored = targetRuntime.bind()) {
+ for (RuntimeScalar captured : target.capturedScalars) {
+ captured.retainThreadCloneClosureCapture();
+ }
+ }
+ }
+ if (source.capturedAggregates != null) {
+ target.capturedAggregates = new RuntimeBase[source.capturedAggregates.length];
+ for (int i = 0; i < source.capturedAggregates.length; i++) {
+ target.capturedAggregates[i] = cloneValue(source.capturedAggregates[i]);
+ }
+ try (PerlRuntime.Binding ignored = targetRuntime.bind()) {
+ for (RuntimeBase captured : target.capturedAggregates) {
+ captured.retainClosureCapture();
+ }
+ }
+ }
+ if (target.capturedScalars != null || target.capturedAggregates != null) {
+ target.refCount = 0;
}
}
@@ -405,18 +442,46 @@ private RuntimeScalar cloneScalar(RuntimeScalar source) {
target.value = source.value;
}
- if (isWeak(source)) weakReferences.add(target);
- if (target.value instanceof RuntimeCode code
- && code.subroutine instanceof CloneablePerlSubroutine cloneable
- && code.__SUB__ == null) {
- code.__SUB__ = target;
- cloneable.setSelfReference(target);
- } else if (target.value instanceof InterpretedCode interpreted && interpreted.__SUB__ == null) {
- interpreted.__SUB__ = target;
+ boolean weak = isWeak(source);
+ if (weak) {
+ weakReferences.add(target);
+ } else {
+ restoreScalarReferenceOwnership(source, target);
+ }
+ if (target.value instanceof RuntimeCode code && code.__SUB__ == null) {
+ // JVM CODE commonly stores its generated implementation in
+ // codeObject/methodHandle with subroutine == null. The old
+ // CloneablePerlSubroutine-only branch therefore left the generated
+ // __SUB__ field null after a thread snapshot, making a second-level
+ // SUPER call restart from the most-derived invocant and recurse.
+ code.restoreClonedSelfReference(target);
}
return target;
}
+ private void restoreScalarReferenceOwnership(RuntimeScalar source, RuntimeScalar target) {
+ if (!source.refCountOwned
+ || (target.type & RuntimeScalarType.REFERENCE_BIT) == 0
+ || !(target.value instanceof RuntimeBase referent)) {
+ return;
+ }
+ if (referent.refCount < 0
+ && (referent instanceof RuntimeHash || referent instanceof RuntimeArray)) {
+ referent.refCount = 0;
+ }
+ if (referent.refCount < 0) return;
+ if (referent.refCount == 0) referent.registerSharedFetchedView();
+ referent.traceRefCount(+1, "RuntimeGraphCloner.restoreScalarReferenceOwnership");
+ referent.recordOwner(target, "thread clone scalar owner");
+ referent.recordActiveOwner(target);
+ referent.refCount++;
+ referent.hadCountedReference = true;
+ target.refCountOwned = true;
+ try (PerlRuntime.Binding ignored = targetRuntime.bind()) {
+ ScalarRefRegistry.registerRef(target);
+ }
+ }
+
private RuntimeArray cloneArray(RuntimeArray source) {
RuntimeArray target = new RuntimeArray(
source.elements instanceof ArraySpecialVariable ? 0 : source.elements.size());
@@ -653,9 +718,17 @@ private void copyScalarMetadata(RuntimeScalar source, RuntimeScalar target) {
}
private void copyBase(RuntimeBase source, RuntimeBase target) {
- target.blessId = cloneBlessId(source.blessId);
target.threadShared = source.threadShared;
target.threadSharedIdentity = source.threadSharedIdentity;
+ target.threadSharedBlessName = source.threadSharedBlessName;
+ target.threadSharedLifecycle = source.threadSharedLifecycle;
+ if (source.threadShared && source.threadSharedBlessName != null) {
+ try (PerlRuntime.Binding ignored = targetRuntime.bind()) {
+ target.blessId = NameNormalizer.getBlessId(source.threadSharedBlessName);
+ }
+ } else {
+ target.blessId = cloneBlessId(source.blessId);
+ }
target.localBindingExists = source.localBindingExists;
target.storedInPackageGlobal = source.storedInPackageGlobal;
target.isPackageGlobalRoot = source.isPackageGlobalRoot;
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java
index 1a27fef09..b5b60ad2c 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeHash.java
@@ -554,6 +554,13 @@ private RuntimeScalar independentSlot(String key, RuntimeScalar value) {
&& (value.containerOwner != this || elements.containsValue(value))) {
return new RuntimeScalar(value);
}
+ // @_ entries alias the caller's scalar. Hash assignment copies the SV
+ // value into a distinct element slot; retaining the alias lets a later
+ // weaken($hash{key}) weaken the caller itself. Test2's weak hub->{ast}
+ // backlink exposed this when an ithread captured the caller object.
+ if (RuntimeCode.isCurrentArgumentAlias(value)) {
+ return new RuntimeScalar(value);
+ }
return value;
}
@@ -618,7 +625,7 @@ public RuntimeScalar get(String key) {
var value = elements.get(key);
if (value != null) {
- return value;
+ return SharedPerlStorage.fetchedElement(this, value);
}
// Lazy autovivification
return new RuntimeHashProxyEntry(this, key);
@@ -697,7 +704,7 @@ public RuntimeScalar get(RuntimeScalar keyScalar) {
// Update the key's byte/UTF-8 flag to match the accessing key's type.
// In Perl, the key's UTF-8 flag is updated on each access.
markKeyByte(key, keyScalar.type == BYTE_STRING);
- yield value;
+ yield SharedPerlStorage.fetchedElement(this, value);
}
// Lazy element autovivification - pass key's byte flag for type tracking
boolean isByteKey = keyScalar.type == BYTE_STRING;
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java
index 5e50c8c59..64edf5bd2 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/RuntimeScalar.java
@@ -284,6 +284,15 @@ public void retainClosureCapture() {
}
}
+ /** Reconstruct capture ownership for an independent ithread snapshot. */
+ void retainThreadCloneClosureCapture() {
+ captureCount++;
+ // An owning lexical slot already protects its referent. Borrowed
+ // argument aliases do not, and the source runtime's other owners are
+ // not a lifetime guarantee in the cloned runtime.
+ if (!refCountOwned) retainClosureCaptureReferent();
+ }
+
public void releaseClosureCapture() {
releaseOneClosureCaptureReferent();
if (captureCount > 0) {
@@ -1769,6 +1778,7 @@ private RuntimeScalar setLargeRefCounted(RuntimeScalar value) {
nb.refCount = 0;
}
if (nb.refCount >= 0) {
+ if (nb.refCount == 0) nb.registerSharedFetchedView();
nb.traceRefCount(+1, "RuntimeScalar.setLargeRefCounted (increment on store)");
nb.recordOwner(this, "setLargeRefCounted store");
nb.recordActiveOwner(this);
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/SharedElementProxy.java b/src/main/java/org/perlonjava/runtime/runtimetypes/SharedElementProxy.java
new file mode 100644
index 000000000..63e790de7
--- /dev/null
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/SharedElementProxy.java
@@ -0,0 +1,36 @@
+package org.perlonjava.runtime.runtimetypes;
+
+/**
+ * A transient lvalue returned when a reference is fetched from shared aggregate
+ * storage. Perl exposes a fresh proxy referent on each FETCH, while assignment
+ * through the scalar must still update the canonical shared slot.
+ */
+final class SharedElementProxy extends RuntimeScalar {
+ private final RuntimeScalar storage;
+
+ SharedElementProxy(RuntimeScalar storage, RuntimeBase localView) {
+ super(storage);
+ this.storage = storage;
+ this.value = localView;
+ }
+
+ @Override
+ public RuntimeScalar set(RuntimeScalar value) {
+ SharedPerlStorage.publishBlessing(value);
+ RuntimeScalar result = storage.set(value);
+ super.set(value);
+ return result;
+ }
+
+ @Override
+ public RuntimeScalar undefine() {
+ RuntimeScalar result = storage.undefine();
+ super.undefine();
+ return result;
+ }
+
+ @Override
+ public RuntimeScalar createReference() {
+ return storage.createReference();
+ }
+}
diff --git a/src/main/java/org/perlonjava/runtime/runtimetypes/SharedPerlStorage.java b/src/main/java/org/perlonjava/runtime/runtimetypes/SharedPerlStorage.java
index b30fc336a..feae5d742 100644
--- a/src/main/java/org/perlonjava/runtime/runtimetypes/SharedPerlStorage.java
+++ b/src/main/java/org/perlonjava/runtime/runtimetypes/SharedPerlStorage.java
@@ -341,10 +341,40 @@ private static void markGraph(RuntimeBase value, Set seen) {
private static void markShared(RuntimeBase value) {
synchronized (value) {
if (value.threadSharedIdentity == null) value.threadSharedIdentity = new Object();
+ if (value.threadSharedLifecycle == null) {
+ value.threadSharedLifecycle = new RuntimeBase.SharedLifecycle();
+ }
+ value.threadSharedBlessName = currentBlessName(value);
value.threadShared = true;
}
}
+ /** Return a fresh runtime-local reference view for a nested shared aggregate. */
+ static RuntimeScalar fetchedElement(RuntimeBase owner, RuntimeScalar stored) {
+ if (!owner.threadShared || stored == null
+ || !(stored.value instanceof RuntimeBase nested)
+ || !nested.threadShared
+ || !(nested instanceof RuntimeArray || nested instanceof RuntimeHash)) {
+ return stored;
+ }
+ PerlRuntime runtime = PerlRuntime.current();
+ RuntimeBase view = new RuntimeGraphCloner(runtime, runtime).cloneGraph(nested);
+ view.threadSharedFetchedView = true;
+ return new SharedElementProxy(stored, view);
+ }
+
+ /** Publish the local class of a shared view when that view is stored. */
+ static void publishBlessing(RuntimeScalar value) {
+ if (value != null && value.value instanceof RuntimeBase base && base.threadShared) {
+ base.threadSharedBlessName = currentBlessName(base);
+ }
+ }
+
+ private static String currentBlessName(RuntimeBase value) {
+ if (value.blessId == 0) return null;
+ return NameNormalizer.getBlessStr(value.blessId);
+ }
+
private static Object sharedIdentity(RuntimeBase value) {
Object identity = value.threadSharedIdentity;
if (identity != null) return identity;
diff --git a/src/main/perl/lib/Plack/Handler/Netty.pm b/src/main/perl/lib/Plack/Handler/Netty.pm
index af4d2c530..a9b0847eb 100644
--- a/src/main/perl/lib/Plack/Handler/Netty.pm
+++ b/src/main/perl/lib/Plack/Handler/Netty.pm
@@ -308,8 +308,9 @@ The handler provides all required PSGI 1.1 environment keys:
=item * C - Error log (STDERR)
-=item * C - \0. PerlOnJava supports explicit ithreads, but
-this handler owns one application runtime and does not invoke it concurrently.
+=item * C - \0 by default. When
+C (or C<-Djperl.runtime.pool.size>) is nonzero, each
+request owns an independent application snapshot and this value is \1.
=item * C - \0 (PerlOnJava doesn't support fork)
diff --git a/src/main/perl/lib/threads.pm b/src/main/perl/lib/threads.pm
index 1d34776eb..d18a94606 100644
--- a/src/main/perl/lib/threads.pm
+++ b/src/main/perl/lib/threads.pm
@@ -120,8 +120,8 @@ C changes string conversion of a thread object from the stable
C form to its numeric thread ID.
The standard C and C declarations configure subsequently
-created platform threads. Java virtual threads reject a non-zero stack-size
-request because their stack size cannot be selected by the application.
+created threads. A non-zero stack-size request selects a platform carrier
+because Java virtual-thread stack size cannot be selected by the application.
Missing values and unknown import options are errors.
=head1 COMPATIBILITY
diff --git a/src/test/java/org/perlonjava/runtime/perlmodule/PlackRuntimePoolTest.java b/src/test/java/org/perlonjava/runtime/perlmodule/PlackRuntimePoolTest.java
new file mode 100644
index 000000000..bd7ab20b4
--- /dev/null
+++ b/src/test/java/org/perlonjava/runtime/perlmodule/PlackRuntimePoolTest.java
@@ -0,0 +1,94 @@
+package org.perlonjava.runtime.perlmodule;
+
+import io.netty.channel.embedded.EmbeddedChannel;
+import io.netty.handler.codec.http.DefaultFullHttpRequest;
+import io.netty.handler.codec.http.FullHttpResponse;
+import io.netty.handler.codec.http.HttpMethod;
+import io.netty.handler.codec.http.HttpVersion;
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.perlonjava.runtime.runtimetypes.*;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Tag("unit")
+class PlackRuntimePoolTest {
+ @Test
+ void requestSlotsOwnIndependentSnapshotRuntimesAndAppRoots() throws Exception {
+ PerlRuntime template = new PerlRuntime().initialize();
+ RuntimeScalar app = new RuntimeScalar(new RuntimeCode(
+ (args, context) -> new RuntimeScalar(200).getList(), null));
+ PlackHandlerNetty.PsgiRuntimePool pool =
+ new PlackHandlerNetty.PsgiRuntimePool(template, app, 2);
+
+ PerlRuntime firstRuntime;
+ PerlRuntime secondRuntime;
+ try (PlackHandlerNetty.PsgiRuntimePool.RequestLease first = pool.checkout();
+ PlackHandlerNetty.PsgiRuntimePool.RequestLease second = pool.checkout()) {
+ firstRuntime = first.runtime();
+ secondRuntime = second.runtime();
+ assertNotSame(template, firstRuntime);
+ assertNotSame(firstRuntime, secondRuntime);
+ assertNotSame(first.app(), second.app());
+ assertEquals(RuntimeScalarType.CODE, first.app().type);
+ }
+
+ assertTrue(firstRuntime.isClosed());
+ assertTrue(secondRuntime.isClosed());
+ try (PlackHandlerNetty.PsgiRuntimePool.RequestLease replacement = pool.checkout()) {
+ assertNotSame(firstRuntime, replacement.runtime());
+ assertNotSame(secondRuntime, replacement.runtime());
+ } finally {
+ pool.close();
+ template.close();
+ }
+ }
+
+ @Test
+ void pooledHandlerAdvertisesMultithreadAndUsesDistinctRequestRuntimes() {
+ PerlRuntime template = new PerlRuntime().initialize();
+ Set observed = ConcurrentHashMap.newKeySet();
+ RuntimeCode callback = new RuntimeCode((args, context) -> {
+ observed.add(PerlRuntime.current());
+ RuntimeHash env = args.get(0).hashDeref();
+ assertEquals(1, env.get("psgi.multithread").getInt());
+
+ RuntimeArray headers = new RuntimeArray(
+ new RuntimeScalar("Content-Type"), new RuntimeScalar("text/plain"));
+ RuntimeArray body = new RuntimeArray(new RuntimeScalar("ok"));
+ RuntimeArray response = new RuntimeArray(
+ new RuntimeScalar(200), headers.createReference(), body.createReference());
+ return response.createReference().getList();
+ }, null);
+ RuntimeScalar app = new RuntimeScalar(callback);
+ PlackHandlerNetty.PsgiRuntimePool pool =
+ new PlackHandlerNetty.PsgiRuntimePool(template, app, 2);
+ try {
+ for (int i = 0; i < 2; i++) {
+ EmbeddedChannel channel = new EmbeddedChannel(
+ new PlackHandlerNetty.PSGIRequestHandler(
+ app, "localhost", 5000, false, template, pool));
+ try {
+ channel.writeInbound(new DefaultFullHttpRequest(
+ HttpVersion.HTTP_1_1, HttpMethod.GET, "/"));
+ FullHttpResponse response = channel.readOutbound();
+ assertNotNull(response);
+ assertEquals(200, response.status().code());
+ assertEquals("ok", response.content().toString(StandardCharsets.ISO_8859_1));
+ response.release();
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
+ assertEquals(2, observed.size());
+ assertFalse(observed.contains(template));
+ } finally {
+ pool.close();
+ template.close();
+ }
+ }
+}
diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/PerlRuntimePoolTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlRuntimePoolTest.java
new file mode 100644
index 000000000..fa7d812fa
--- /dev/null
+++ b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlRuntimePoolTest.java
@@ -0,0 +1,98 @@
+package org.perlonjava.runtime.runtimetypes;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Tag("unit")
+class PerlRuntimePoolTest {
+ @Test
+ void configurationDefaultsOffAndRejectsInvalidValues() {
+ assertEquals(0, PerlRuntimePool.resolveSize(null, null));
+ assertEquals(3, PerlRuntimePool.resolveSize(null, " 3 "));
+ assertEquals(2, PerlRuntimePool.resolveSize("2", "4"));
+ assertThrows(IllegalArgumentException.class,
+ () -> PerlRuntimePool.resolveSize("-1", null));
+ assertThrows(IllegalArgumentException.class,
+ () -> PerlRuntimePool.resolveSize("many", null));
+ }
+
+ @Test
+ void checkoutIsExclusiveAndReturnResetsTheSameRuntime() throws Exception {
+ PerlRuntimePool pool = new PerlRuntimePool(1);
+ PerlRuntime first;
+ try (PerlRuntimePool.Lease lease = pool.checkout(Duration.ofSeconds(1))) {
+ first = lease.runtime();
+ first.execute(() -> GlobalVariable.getGlobalVariable("main::tenant").set(41));
+ assertThrows(IllegalStateException.class,
+ () -> pool.checkout(Duration.ofMillis(10)));
+ }
+
+ try (PerlRuntimePool.Lease lease = pool.checkout(Duration.ofSeconds(1))) {
+ assertSame(first, lease.runtime());
+ lease.runtime().execute(() ->
+ assertEquals(RuntimeScalarType.UNDEF,
+ GlobalVariable.getGlobalVariable("main::tenant").type));
+ } finally {
+ pool.close();
+ }
+ }
+
+ @Test
+ void closeDoesNotResetAnActiveLeaseAndClosesItOnReturn() throws Exception {
+ PerlRuntimePool pool = new PerlRuntimePool(1);
+ PerlRuntimePool.Lease lease = pool.checkout(Duration.ofSeconds(1));
+ PerlRuntime runtime = lease.runtime();
+ pool.close();
+ assertFalse(runtime.isClosed());
+ lease.close();
+ assertTrue(runtime.isClosed());
+ assertThrows(IllegalStateException.class,
+ () -> pool.checkout(Duration.ZERO));
+ }
+
+ @Test
+ void concurrentChurnNeverHandsOneRuntimeToTwoOwners() throws Exception {
+ PerlRuntimePool pool = new PerlRuntimePool(2);
+ CountDownLatch start = new CountDownLatch(1);
+ CountDownLatch done = new CountDownLatch(8);
+ java.util.Set active = java.util.Collections.synchronizedSet(
+ java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()));
+ java.util.concurrent.atomic.AtomicReference failure =
+ new java.util.concurrent.atomic.AtomicReference<>();
+
+ for (int i = 0; i < 8; i++) {
+ Thread.ofPlatform().daemon(true).start(() -> {
+ try {
+ assertTrue(start.await(5, TimeUnit.SECONDS));
+ for (int round = 0; round < 20; round++) {
+ int value = round;
+ try (PerlRuntimePool.Lease lease = pool.checkout(Duration.ofSeconds(5))) {
+ PerlRuntime runtime = lease.runtime();
+ if (!active.add(runtime)) throw new AssertionError("duplicate checkout");
+ try {
+ runtime.execute(() -> GlobalVariable.getGlobalVariable("main::round")
+ .set(value));
+ } finally {
+ active.remove(runtime);
+ }
+ }
+ }
+ } catch (Throwable thrown) {
+ failure.compareAndSet(null, thrown);
+ } finally {
+ done.countDown();
+ }
+ });
+ }
+ start.countDown();
+ assertTrue(done.await(30, TimeUnit.SECONDS));
+ pool.close();
+ assertNull(failure.get(), () -> String.valueOf(failure.get()));
+ }
+}
diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/PerlThreadTerminalResourceReleaseTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlThreadTerminalResourceReleaseTest.java
new file mode 100644
index 000000000..74f527cbc
--- /dev/null
+++ b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlThreadTerminalResourceReleaseTest.java
@@ -0,0 +1,51 @@
+package org.perlonjava.runtime.runtimetypes;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Tag("unit")
+class PerlThreadTerminalResourceReleaseTest {
+ @Test
+ void joinedAliasRetainsMetadataWithoutRetainingChildRuntime() throws Exception {
+ PerlRuntime parent = new PerlRuntime().initialize();
+ try (PerlRuntime.Binding ignored = parent.bind()) {
+ PerlThreadControlBlock thread = PerlThreadControlBlock.create(parent,
+ child -> new RuntimeScalar(7)).start();
+
+ PerlThreadControlBlock.Completion completion = thread.join();
+ assertEquals(7, ((RuntimeScalar) completion.value()).getInt());
+ assertNotNull(thread.childRuntime());
+
+ thread.releaseTerminalResources();
+
+ assertNull(thread.childRuntime());
+ assertSame(thread, parent.threadRegistry().getKnown(thread.id()));
+ assertEquals(PerlThreadControlBlock.State.JOINED, thread.state());
+ assertNull(thread.error());
+ } finally {
+ parent.close();
+ }
+ }
+
+ @Test
+ void detachedCompletionReleasesChildRuntimeAutomatically() throws Exception {
+ PerlRuntime parent = new PerlRuntime().initialize();
+ try (PerlRuntime.Binding ignored = parent.bind()) {
+ PerlThreadControlBlock thread = PerlThreadControlBlock.create(parent,
+ child -> new RuntimeScalar(1)).start();
+ thread.detach();
+ thread.platformThread().join(TimeUnit.SECONDS.toMillis(5));
+
+ assertFalse(thread.platformThread().isAlive());
+ assertNull(thread.childRuntime());
+ assertSame(thread, parent.threadRegistry().getKnown(thread.id()));
+ assertEquals(PerlThreadControlBlock.State.DETACHED, thread.state());
+ } finally {
+ parent.close();
+ }
+ }
+}
diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/PerlThreadVirtualDefaultTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlThreadVirtualDefaultTest.java
new file mode 100644
index 000000000..1181b2990
--- /dev/null
+++ b/src/test/java/org/perlonjava/runtime/runtimetypes/PerlThreadVirtualDefaultTest.java
@@ -0,0 +1,27 @@
+package org.perlonjava.runtime.runtimetypes;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Tag("unit")
+class PerlThreadVirtualDefaultTest {
+ @Test
+ void nonzeroStackRequestFallsBackToAPlatformCarrier() throws Exception {
+ CountDownLatch ran = new CountDownLatch(1);
+ Thread thread = PerlThreadExecutionPolicy.resolve("virtual", null)
+ .effectiveForStackSize(1024 * 1024)
+ .unstarted(71, 1024 * 1024, ran::countDown);
+
+ assertFalse(thread.isVirtual());
+ assertEquals("perl-ithread-71", thread.getName());
+ thread.start();
+ assertTrue(ran.await(5, TimeUnit.SECONDS));
+ thread.join(TimeUnit.SECONDS.toMillis(5));
+ assertFalse(thread.isAlive());
+ }
+}
diff --git a/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeCloneCaptureOwnershipTest.java b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeCloneCaptureOwnershipTest.java
new file mode 100644
index 000000000..bfdbefaf8
--- /dev/null
+++ b/src/test/java/org/perlonjava/runtime/runtimetypes/RuntimeCodeCloneCaptureOwnershipTest.java
@@ -0,0 +1,58 @@
+package org.perlonjava.runtime.runtimetypes;
+
+import org.junit.jupiter.api.Tag;
+import org.junit.jupiter.api.Test;
+import org.perlonjava.app.cli.CompilerOptions;
+import org.perlonjava.app.scriptengine.PerlLanguageProvider;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@Tag("unit")
+class RuntimeCodeCloneCaptureOwnershipTest {
+
+ @Test
+ void clonedClosureOwnsCapturedReferentOnBothBackends() throws Exception {
+ for (boolean interpreter : new boolean[]{false, true}) {
+ PerlRuntime parent = new PerlRuntime();
+ PerlRuntime child = new PerlRuntime();
+ RuntimeScalar parentCode = compileCapturedObjectClosure(parent, interpreter);
+ RuntimeScalar childCode = (RuntimeScalar) new RuntimeGraphCloner(parent, child)
+ .cloneGraph(parentCode);
+
+ RuntimeCode cloned = (RuntimeCode) childCode.value;
+ RuntimeScalar captured = cloned.capturedScalars[0];
+ RuntimeHash capturedObject = (RuntimeHash) captured.value;
+ int captureCount = captured.captureCount;
+
+ assertNotSame(parentCode, childCode);
+ assertSame(captured, cloned.closedOverVariables.values().iterator().next());
+ assertTrue(captureCount > 0);
+ assertEquals("alive", invoke(child, childCode));
+ assertEquals(captureCount, captured.captureCount);
+ assertSame(capturedObject, captured.value);
+ }
+ }
+
+ private static RuntimeScalar compileCapturedObjectClosure(
+ PerlRuntime runtime, boolean interpreter) throws Exception {
+ try (PerlRuntime.Binding ignored = runtime.bind()) {
+ CompilerOptions options = new CompilerOptions();
+ options.fileName = "";
+ options.useInterpreter = interpreter;
+ options.code = "package CloneCaptureGuard; sub DESTROY {} "
+ + "package main; my $guard = bless {}, 'CloneCaptureGuard'; "
+ + "sub { $guard; 'alive' }";
+ return PerlLanguageProvider.executePerlCode(options, false).scalar();
+ }
+ }
+
+ private static String invoke(PerlRuntime runtime, RuntimeScalar code) {
+ try (PerlRuntime.Binding ignored = runtime.bind()) {
+ return RuntimeCode.apply(code, new RuntimeArray(), RuntimeContextType.SCALAR)
+ .scalar().toString();
+ }
+ }
+}
diff --git a/src/test/resources/unit/threads_captured_object_lifetime.t b/src/test/resources/unit/threads_captured_object_lifetime.t
new file mode 100644
index 000000000..1945ce4b3
--- /dev/null
+++ b/src/test/resources/unit/threads_captured_object_lifetime.t
@@ -0,0 +1,43 @@
+use strict;
+use warnings;
+
+use threads;
+use Scalar::Util qw(weaken);
+
+print "1..2\n";
+
+{
+ package ThreadCapturedGuard;
+
+ our $premature = 0;
+
+ sub new {
+ my $self = bless { attached => 0 }, $_[0];
+ $self->{weak_self} = $self;
+ Scalar::Util::weaken($self->{weak_self});
+ return $self;
+ }
+ sub attach { $_[0]->{attached} = 1 }
+ sub run { $_[1]->() }
+ sub detach {
+ die "captured guard was detached early" unless $_[0]->{attached};
+ $_[0]->{attached} = 0;
+ }
+ sub DESTROY { $premature++ if $_[0]->{attached} }
+}
+
+my $guard = ThreadCapturedGuard->new;
+my $thread = threads->create(sub {
+ $guard->attach;
+ $guard->run(sub { 1 });
+ $guard->detach;
+ return $ThreadCapturedGuard::premature;
+});
+
+my $child_premature = $thread->join;
+print !$child_premature
+ ? "ok 1 - captured child object survives through explicit detach\n"
+ : "not ok 1 - captured child object survives through explicit detach\n";
+print $guard->{attached} == 0
+ ? "ok 2 - parent object remains independent\n"
+ : "not ok 2 - parent object remains independent\n";
diff --git a/src/test/resources/unit/threads_qr_literal_validation.t b/src/test/resources/unit/threads_qr_literal_validation.t
new file mode 100644
index 000000000..df331acb2
--- /dev/null
+++ b/src/test/resources/unit/threads_qr_literal_validation.t
@@ -0,0 +1,15 @@
+use strict;
+use warnings;
+use threads;
+
+print "1..2\n";
+
+my $ok = eval q{
+ my $regex = threads->new(sub { qr/[a\Q]]\Ec/ })->join();
+ 1;
+};
+
+print((!defined($ok) ? "ok" : "not ok"),
+ " 1 - malformed constant qr fails in the creating runtime\n");
+print(($@ =~ /Unmatched \[/ ? "ok" : "not ok"),
+ " 2 - parent eval receives the regex compilation diagnostic\n");
diff --git a/src/test/resources/unit/threads_regex_fail_verb.t b/src/test/resources/unit/threads_regex_fail_verb.t
new file mode 100644
index 000000000..434a8bee6
--- /dev/null
+++ b/src/test/resources/unit/threads_regex_fail_verb.t
@@ -0,0 +1,17 @@
+use strict;
+use warnings;
+use threads;
+
+print "1..4\n";
+
+print(("aaa" !~ /a*(*FAIL)/ ? "ok" : "not ok"),
+ " 1 - FAIL forces the direct branch to backtrack and fail\n");
+print(("aaa" !~ /a*(*F)/ ? "ok" : "not ok"),
+ " 2 - F is the short spelling of FAIL\n");
+
+my $long = threads->create(sub { qr/a*(*FAIL)/ })->join;
+my $short = threads->create(sub { qr/a*(*F)/ })->join;
+print(("aaa" !~ $long ? "ok" : "not ok"),
+ " 3 - a FAIL regex retains its semantics across snapshot join\n");
+print(("aaa" !~ $short ? "ok" : "not ok"),
+ " 4 - a short FAIL regex retains its semantics across snapshot join\n");
diff --git a/src/test/resources/unit/threads_shared_final_destroy.t b/src/test/resources/unit/threads_shared_final_destroy.t
new file mode 100644
index 000000000..6bda0f84f
--- /dev/null
+++ b/src/test/resources/unit/threads_shared_final_destroy.t
@@ -0,0 +1,60 @@
+use strict;
+use warnings;
+use threads;
+use threads::shared;
+
+my $destroyed :shared = 0;
+
+{
+ package SharedFinalOwner;
+
+ sub DESTROY {
+ lock($destroyed);
+ ++$destroyed;
+ }
+}
+
+print "1..5\n";
+
+my $inner = shared_clone(bless({ value => 7 }, 'SharedFinalOwner'));
+my $root = shared_clone({ inner => $inner });
+undef $inner;
+
+# The temporary ordinary object passed to shared_clone is a distinct owner.
+# Count only the canonical shared object from this point onward.
+$destroyed = 0;
+
+my $thread = threads->create(sub {
+ my $view = $root->{inner};
+ delete $root->{inner};
+ undef $view;
+ return $destroyed;
+});
+
+my $child_count = $thread->join;
+print(($child_count == 1 ? "ok" : "not ok"),
+ " 1 - final shared owner is destroyed in the child runtime\n");
+
+undef $root;
+print(($destroyed == 1 ? "ok" : "not ok"),
+ " 2 - canonical shared object DESTROY runs exactly once\n");
+
+print(($destroyed == $child_count ? "ok" : "not ok"),
+ " 3 - parent observes the child destructor side effect\n");
+
+my $second = shared_clone(bless({ value => 8 }, 'SharedFinalOwner'));
+my $second_root = shared_clone({ inner => $second });
+undef $second;
+$destroyed = 1;
+
+my $reader = threads->create(sub {
+ my $view = $second_root->{inner};
+ undef $view;
+ return $destroyed;
+});
+print(($reader->join == 1 ? "ok" : "not ok"),
+ " 4 - releasing a fetched view keeps canonical storage alive\n");
+
+delete $second_root->{inner};
+print(($destroyed == 2 ? "ok" : "not ok"),
+ " 5 - canonical deletion destroys after fetched views are gone\n");
diff --git a/src/test/resources/unit/threads_shared_nested_proxies.t b/src/test/resources/unit/threads_shared_nested_proxies.t
new file mode 100644
index 000000000..ddef8bd01
--- /dev/null
+++ b/src/test/resources/unit/threads_shared_nested_proxies.t
@@ -0,0 +1,52 @@
+use strict;
+use warnings;
+use threads;
+use threads::shared;
+use Scalar::Util qw(refaddr weaken);
+
+print "1..10\n";
+my $test = 0;
+sub ok {
+ my ($condition, $name) = @_;
+ ++$test;
+ print(($condition ? "ok" : "not ok"), " $test - $name\n");
+}
+
+{
+ package SharedInner;
+ sub value { $_[0]{value} }
+}
+
+{
+ package SharedPublished;
+ sub value { $_[0]{value} }
+}
+
+my $source = { inner => bless({ value => 1 }, 'SharedInner') };
+my $shared = shared_clone($source);
+ok($shared->{inner}->value == 1, 'shared_clone preserves nested content');
+ok($source->{inner}{value} == 1 && !is_shared($source),
+ 'shared_clone leaves its source isolated');
+
+my $first = $shared->{inner};
+my $second = $shared->{inner};
+ok(refaddr($first) != refaddr($second), 'each nested fetch returns a fresh proxy view');
+bless($first, 'SharedPublished');
+ok(ref($shared->{inner}) eq 'SharedInner', 'local reblessing is not published by fetch');
+$first->{value} = 7;
+ok($shared->{inner}{value} == 7, 'proxy mutations use common shared backing');
+$shared->{inner} = $first;
+ok(ref($shared->{inner}) eq 'SharedPublished', 'storing a proxy publishes its blessing');
+
+my $cycle = shared_clone({});
+$cycle->{self} = $cycle;
+my $cycle_view = $cycle->{self};
+ok(refaddr($cycle_view) != refaddr($cycle), 'cycle traversal returns a fresh proxy view');
+$cycle_view->{seen} = 1;
+ok($cycle->{seen} == 1, 'cycle views retain common backing');
+
+my $weak_target = $shared->{inner};
+weaken($weak_target);
+ok(!defined($weak_target), 'an otherwise unowned weak proxy view is released locally');
+ok($shared->{inner}{value} == 7,
+ 'releasing a local weak view does not remove canonical shared storage');
diff --git a/src/test/resources/unit/threads_super_chain.t b/src/test/resources/unit/threads_super_chain.t
new file mode 100644
index 000000000..a4a81d396
--- /dev/null
+++ b/src/test/resources/unit/threads_super_chain.t
@@ -0,0 +1,33 @@
+use strict;
+use warnings;
+use threads;
+
+print "1..2\n";
+
+{
+ package ThreadSuperGrandparent;
+ sub identify { 'grandparent' }
+}
+
+{
+ package ThreadSuperParent;
+ our @ISA = ('ThreadSuperGrandparent');
+ sub identify { 'parent-' . shift->SUPER::identify }
+}
+
+{
+ package ThreadSuperChild;
+ our @ISA = ('ThreadSuperParent');
+ sub identify { 'child-' . shift->SUPER::identify }
+}
+
+my $expected = 'child-parent-grandparent';
+print(ThreadSuperChild->identify eq $expected
+ ? "ok 1 - direct chained SUPER dispatch\n"
+ : "not ok 1 - direct chained SUPER dispatch\n");
+
+my $thread = threads->create(sub { ThreadSuperChild->identify });
+my $result = $thread->join;
+print(defined($result) && $result eq $expected
+ ? "ok 2 - cloned chained SUPER dispatch\n"
+ : "not ok 2 - cloned chained SUPER dispatch\n");
diff --git a/src/test/resources/unit/weak_assignment_destination.t b/src/test/resources/unit/weak_assignment_destination.t
new file mode 100644
index 000000000..3fc7df33e
--- /dev/null
+++ b/src/test/resources/unit/weak_assignment_destination.t
@@ -0,0 +1,34 @@
+use strict;
+use warnings;
+use Scalar::Util qw(isweak weaken);
+
+print "1..4\n";
+
+my $object = bless {}, 'WeakAssignmentDestination';
+my %holder;
+
+weaken($holder{ast} = $object);
+
+print(!isweak($object)
+ ? "ok 1 - assignment source remains strong\n"
+ : "not ok 1 - assignment source remains strong\n");
+print(isweak($holder{ast})
+ ? "ok 2 - assignment destination is weak\n"
+ : "not ok 2 - assignment destination is weak\n");
+
+sub attach_weakly {
+ my ($argument, $target) = @_;
+ weaken($target->{ast} = $argument);
+ return isweak($argument);
+}
+
+my $argument_object = bless {}, 'WeakAssignmentArgument';
+my $argument_holder = {};
+my $argument_became_weak = attach_weakly($argument_object, $argument_holder);
+
+print(!$argument_became_weak && !isweak($argument_object)
+ ? "ok 3 - argument alias remains strong\n"
+ : "not ok 3 - argument alias remains strong\n");
+print(isweak($argument_holder->{ast})
+ ? "ok 4 - argument assignment destination is weak\n"
+ : "not ok 4 - argument assignment destination is weak\n");