diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-30 23:01:25 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-30 23:09:56 +0900 |
| commit | d3bc3354c9705dfc6dc5e9b9adb5eb64d41e4c49 (patch) | |
| tree | a745ecc3403104d5a34f29964362e239e8f5675b /docs/dev | |
| parent | 057f3b8de26293319e265c1d86d9a1153124f3c7 (diff) | |
| download | php-shirabe-d3bc3354c9705dfc6dc5e9b9adb5eb64d41e4c49.tar.gz php-shirabe-d3bc3354c9705dfc6dc5e9b9adb5eb64d41e4c49.tar.zst php-shirabe-d3bc3354c9705dfc6dc5e9b9adb5eb64d41e4c49.zip | |
feat(plugin): serve ProcessExecutor as a proxy stub
Composer reaches this class two ways: the object graph hands one out through
Composer::getLoop()->getProcessExecutor(), and plugins write
`new ProcessExecutor($io)` freely. Both bind to a Rust-side entity, so the
timeout the run shares -- seeded from process-timeout and rewritten while the
run is in flight -- has one value instead of one per world, and the executor
can still be passed to the classes that take one (`new Filesystem($process)`).
Three things the stub generator was missing came with it:
- By-ref parameters. The call carries their positions and the answer carries
what each holds afterwards; a position the answer omits was never assigned
to, which is what PHP does with an untouched by-ref parameter.
ProcessExecutor::execute is the only one on a proxied class.
- Argument arity, reproduced where the real body reads func_num_args().
execute($cmd) forwards the child's output and execute($cmd, $out) captures
it, and nothing but the argument count separates the two.
- Static methods that cannot run in the worker. One that reads a static
property the Rust side owns, or that reaches a guarded class, forwards
through __shirabeCallStatic instead of being materialized. That also fixes
Filesystem::isLocalPath and getPlatformPath, whose materialized bodies
called the guarded Composer\Util\Platform.
The async surface stays an explicit error. executeAsync resolves its promise
with a Symfony Process, whose proc_open() resource and pipes belong to
whichever process called start(), so a Rust-side spawn has none to hand back;
running the real start() in the worker needs a promise representation that
crosses the boundary unresolved.
The fixture project drives the whole synchronous surface from plugin code and
compares the trace against upstream Composer byte for byte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'docs/dev')
| -rw-r--r-- | docs/dev/php-rpc.md | 20 | ||||
| -rw-r--r-- | docs/dev/plugin-class-classification.md | 97 | ||||
| -rw-r--r-- | docs/dev/plugin-stub-generation.md | 27 |
3 files changed, 91 insertions, 53 deletions
diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md index eb93db19..aebf0097 100644 --- a/docs/dev/php-rpc.md +++ b/docs/dev/php-rpc.md @@ -184,10 +184,24 @@ Notable internal helpers: The runtime service endpoint (handle 0) answers `__shirabe_find_file` (autoload lookups), `__shirabe_run_rust_command` — the reverse half of the two-world command split: a `\Shirabe\RustCommandStub` forwards its stringified input here and the built-in command runs in -the Rust process, against the Rust-side application state — and `__shirabeConstruct`, which +the Rust process, against the Rust-side application state — `__shirabeConstruct`, which allocates the Rust entity behind a `new SomeProxiedClass(...)` written by plugin code and -answers with `[rhandle, epoch]`. Classes whose entity Rust cannot build are an explicit error -naming the class. +answers with `[rhandle, epoch]`, and `__shirabeCallStatic`, which takes `[class, method, args]` +and runs a static method no handle identifies a receiver for. Classes and static methods Rust +cannot answer are an explicit error naming them. + +### By-ref parameters + +`out_param_positions` names the by-ref parameters of the called method, as the caller declared +them; the answering `Return` carries `out_params`, a map from the same positions to the value +each parameter holds afterwards, and the caller assigns those back into its own variables. A +position the answer leaves out was never assigned to, which is what PHP does with an untouched +by-ref parameter — so an out-param is genuinely optional rather than defaulting to null. + +A generated stub fills both halves mechanically from the real signature +(`docs/dev/plugin-stub-generation.md`). `Composer\Util\ProcessExecutor::execute` is the method +this exists for: it is the only by-ref parameter on Composer's public surface that belongs to a +proxied class. ## Proxy stubs, runtime classes and guards diff --git a/docs/dev/plugin-class-classification.md b/docs/dev/plugin-class-classification.md index 1c7aec09..516cf226 100644 --- a/docs/dev/plugin-class-classification.md +++ b/docs/dev/plugin-class-classification.md @@ -139,11 +139,15 @@ design-sensitive. The class writes to `static` properties. Sub-classified by the disposition list (see exception lists): `memo-cache` (pure memoization, each world may compute its own: `Git::$version`, `Platform::$isDocker`, …), `seed-once` -(copied from the Rust side once at child startup: -`ProcessExecutor::$timeout`, which Composer seeds from config), -`needs-sync` (genuinely shared process state: `Platform`'s env table), or -`needs-review` (default for newly appearing ones — the run fails loudly -until a human files it). +(copied from the Rust side once at child startup; nothing is filed this way +today), `needs-sync` (genuinely shared process state: `Platform`'s env table, +`ProcessExecutor::$timeout`), or `needs-review` (default for newly appearing +ones — the run fails loudly until a human files it). + +A `needs-sync` static is not actually synced by copying it: the child holds no +copy at all, because the stub forwards the accessors that read and write it +(`__shirabeCallStatic`, see `docs/dev/plugin-stub-generation.md`). The +disposition records that the state is shared, not the mechanism. #### throwable @@ -369,23 +373,35 @@ Earlier design analysis assumed no public getter returns a `Composer::getLoop()` → `Loop::getProcessExecutor(): ?ProcessExecutor` / `Loop::getHttpDownloader(): HttpDownloader` make both reachable. The graph-owned `ProcessExecutor` instance must therefore be proxied (its job -queue is driven by the Rust loop), while the design intent — plugins using -it as a stateless utility — survives only for plugin-`new`ed instances. -This is exactly the dual-instantiation situation the -`plugin-constructible` attribute exists to surface. +queue is driven by the Rust loop), which is the dual-instantiation situation +the `plugin-constructible` attribute exists to surface. + +`ProcessExecutor` is one class in one category for both routes: it is a +`rust-proxy` stub, and a plugin-`new`ed instance allocates a Rust-side entity +of its own rather than a second, unconnected executor. That is what keeps +`$timeout` — process-wide state Composer seeds from `process-timeout` and +`RunScriptCommand` rewrites mid-run — readable and writable from both worlds +through one value, and it is what lets the executor stay a constructor +argument of the classes that take one (`new Filesystem($process)` is what +plugins actually write). Children are spawned in the Rust process either way, +which is also correct for stdio: the worker inherits the Rust process's +standard streams, so both worlds see the same terminal. -Both routes raise an explicit error today, and which one gets a story first -is still open. The graph-owned instances cannot be obtained at all: the -`Composer` proxy answers `getLoop()` with an explicit error, and `Loop`, -`ProcessExecutor` and `HttpDownloader` are guarded classes, so a -plugin-`new`ed instance is an explicit error too rather than a second -instance the Rust side never sees. +What stays an explicit error is the async surface — `executeAsync()`, +`wait()`, `enableAsync()`, `countActiveJobs()` — for two reasons that outlive +the choice of category. `executeAsync()` resolves its promise with a +`Symfony\Component\Process\Process`, whose state is the `proc_open()` +resource and OS pipes of whichever process called `start()`, so a Rust-side +spawn has no such object to hand back; and driving it needs a promise +representation that crosses the boundary unresolved, which the execution model +does not have. Neither is specific to how the executor is obtained: a plugin +can reach the async path on an instance of its own, since `enableAsync()`, +`wait()` and `countActiveJobs()` are all public (`@internal` is a docblock +note). -An earlier argument for leaving plugin-`new`ed instances alone does not -hold: `executeAsync()` does throw a `LogicException` unless `enableAsync()` -ran first, but `enableAsync()`, `wait()` and `countActiveJobs()` are all -public — `@internal` is a docblock note — so nothing stops a plugin from -driving its own instance through the async path. +`HttpDownloader` and `Loop` remain guarded, so `Composer::getLoop()` is still +an explicit error and the graph's own executor is not reachable yet. Serving +it needs only a `Loop` stub, since the executor's surface is already served. ### Dual instantiation @@ -406,39 +422,24 @@ stub constructor forwards to `__shirabeConstruct`, the Rust side allocates the entity and answers with its handle, and the plugin-`new`ed object is then the same entity the graph sees. It is filled in per class, driven by the explicit errors real plugins hit — today `Package`, `CompletePackage`, -the three alias packages, the five solver operations and -`Composer\Util\Filesystem` can be built this way, the last one only without -a `ProcessExecutor` argument (that class has no stub, so the argument could -only be an instance the Rust side never sees). Every other proxied class -answers with an explicit error naming it. +the three alias packages, the five solver operations, +`Composer\Util\Filesystem` (with or without its `ProcessExecutor` argument) +and `Composer\Util\ProcessExecutor` can be built this way. Every other +proxied class answers with an explicit error naming it. -`JsonFile`, `ArrayLoader` and `ProcessExecutor` are still undecided, so the -classes above stay `unsupported` — but a guard now shadows each of them in -the child, so touching one is an explicit error instead of real code -running against a second instance. +`JsonFile` and `ArrayLoader` are still undecided, so the classes above stay +`unsupported` — but a guard now shadows each of them in the child, so +touching one is an explicit error instead of real code running against a +second instance. The demotion rule has not been taught about the construction stories: it demotes for constructing any `rust-proxy` service, whether or not that service can now be built. `Composer\Package\Archiver\ArchivableFilesFinder` -is `unsupported` for `new Filesystem` alone, which no longer forks any -state; promoting such a class means feeding the per-class construction -stories back into the rule. - -### Process: dual instantiation split by caller - -`ProcessExecutor::executeAsync()` resolves its promise with a -`Symfony\Component\Process\Process` instance, so it may cross the language -boundary despite being a wholesale-`php-native` vendor class. A `Process` -can't be reconstructed PHP-side from Rust-generated data because its state -is stored in a `resource` created by `proc_open()`. - -Proposed resolution (not adopted): split `ProcessExecutor`'s Rust -implementation by caller. -Rust-ported Composer code (`VersionGuesser`, `Git`, …) calls `execute_async()` -directly and spawns in Rust. A plugin holding a `ProcessExecutor` handle -(`Loop::getProcessExecutor()`) instead hits the `rust-proxy` stub's RPC entry, -which forwards the spawn to the PHP child so the real `Process::start()` runs -there. The plugin gets the genuine object, never a fake one. +is `unsupported` for `new Filesystem` alone, and the VCS/auth belt is +`unsupported` for `new ProcessExecutor` alone; neither forks any state any +more. Promoting them means feeding the per-class construction stories back +into the rule — and, for the belt, deciding `HttpDownloader` too, since +those classes take one and no route to it exists yet. ### Package and CompletePackage diff --git a/docs/dev/plugin-stub-generation.md b/docs/dev/plugin-stub-generation.md index e2b6d848..a14d7ce1 100644 --- a/docs/dev/plugin-stub-generation.md +++ b/docs/dev/plugin-stub-generation.md @@ -88,11 +88,32 @@ the generator's vendor directory or the classifier report is unavailable. the class's own public methods already cover their surface. * Methods returning `self`/`static` perform the RPC and then `return $this;` to preserve identity instead of round-tripping the handle. +* **By-ref parameters** are declared `&$name` on the stub as well. The call + carries their positions, and the answer carries the value each of them holds + afterwards, which the stub assigns back (see "By-ref parameters" in + `docs/dev/php-rpc.md`). A position the answer omits is left alone, so a + parameter the callee never assigned to keeps the value it had. +* **Arity** is reproduced when — and only when — the real method body calls + `func_num_args()`, in which case the stub trims the argument list to what the + caller actually passed instead of sending the declared defaults. Sending them + regardless would make the Rust side answer a call the plugin never made: + `ProcessExecutor::execute($cmd)` forwards the child's output, while + `execute($cmd, $out)` captures it, and only the argument count separates them. + A body calling `func_get_args()` fails generation instead. * **Class constants, static methods and public static properties** are materialized verbatim from the real source (they read no instance state and run locally in the worker), together with any non-public static helpers the methods call. Constants keep their declared visibility, so a non-public one stays unreadable from outside the stub as it is in the real class. +* A static method is materialized only when it can actually run in the worker. + One that reads a **static property** — whose value the Rust side owns, as + `ProcessExecutor::$timeout` does — or that reaches a class a **guard** + shadows there — as `ProcessExecutor::escape()` reaches `Composer\Util\Platform` + — forwards instead, through `__shirabeCallStatic` on handle 0, carrying a + comment that names the reason. The test covers the transitive closure of the + non-public static helpers the method calls, since those are materialized with + it. A forwarded static may not take by-ref parameters; that combination fails + generation. * **Instance properties** are not declared on the stub, whatever their visibility: they are entity state. Every root stub instead carries `__get`/`__set`/`__isset`/`__unset` forwarders, so each access reaches the @@ -137,8 +158,10 @@ Generation fails — instead of emitting something quietly wrong — on: * a target missing from the classifier report, classified other than `rust-proxy`/`contract`, or a report carrying violations, -* by-ref or variadic parameters (in constructors too), static interface - methods, magic methods other than `__toString`/`__clone`, +* variadic parameters (in constructors too), by-ref parameters in a + constructor or in a forwarded static method, `func_get_args()` in a forwarded + body, static interface methods, magic methods other than + `__toString`/`__clone`, * an omitted override diverging from the inherited stub signature, * a subclass target listed before its base class, or extending a class that is neither a target nor provided by `php/runtime/`, |
