Skip to content

Commit a6e94a5

Browse files
committed
fix(package): harden restart-safe lifecycle state
1 parent 7aacd54 commit a6e94a5

10 files changed

Lines changed: 374 additions & 31 deletions

CHANGELOG.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ elprobe run <package>
126126
and a matching installed `package-state.yml` entry before PMMP can load it.
127127
- Boot cleanup now removes orphaned managed proxy folders whose package state
128128
was removed, preventing removed packages from reappearing on the next boot.
129+
- Generated package-backed proxies now record `loaded-at` and `loaded-path` in
130+
their marker during bootstrap. EasyLibrary preserves that runtime marker when
131+
refreshing proxy mounts, so staged install/update state is cleared only after
132+
the matching package really booted.
129133

130134
### Standalone shadow guard and switch flow
131135

@@ -161,8 +165,11 @@ elprobe run <package>
161165
data cleanup.
162166
- Kept update, remove and rollback restart-based. Loaded PHP code is not
163167
hot-unloaded from the current PMMP session.
164-
- Cleanup currently targets temporary data only. Installed package versions and
165-
backups remain preserved for rollback safety.
168+
- Removal now blocks when another installed package still requires the target,
169+
for example `libpacket` while `libcommand` is installed.
170+
- Cleanup targets only `.downloads`, `.staging`, `_incoming*` and `.incoming*`
171+
temporary paths. Installed package versions and backups remain preserved for
172+
rollback safety.
166173
- Validated the leaf-package remove/reinstall cycle with `libform`:
167174
remove while active, restart detached, reinstall from release asset, restart
168175
active again and pass `elprobe run libform`.
@@ -175,6 +182,8 @@ elprobe run <package>
175182
- Doctor reports missing, incomplete or divergent proxies with repair guidance.
176183
- Stale restart-required markers are cleared once the package-backed plugin is
177184
confirmed active.
185+
- Pending restart state now remains visible when an active runtime does not
186+
match the staged package path or was loaded before the staged operation.
178187
- Doctor now treats missing required package dependencies as a problem only for
179188
packages that are installed/staged, so a clean server with available-but-not
180189
installed packages no longer reports false dependency failures.

changelogs/3.0.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,134 @@ elprobe run libform
271271
functional=yes source=package-backed-plugin
272272
```
273273

274+
### Pending restart runtime marker hardening
275+
276+
Package-backed proxy plugins are discovered by PMMP before EasyLibrary itself
277+
is enabled. That boot order matters.
278+
279+
Before the hardening, this sequence could produce a false pending state:
280+
281+
1. a package install/update was staged;
282+
2. the server restarted;
283+
3. the generated proxy plugin loaded the new package successfully;
284+
4. EasyLibrary refreshed the proxy mount marker after loading;
285+
5. the refresh rewrote `.easylib-package-plugin.json` without remembering that
286+
the proxy had already booted.
287+
288+
The status service could then still report:
289+
290+
```text
291+
installed-pending-restart
292+
```
293+
294+
even though PMMP had already loaded the package-backed plugin for the current
295+
session.
296+
297+
The generated proxy now records runtime evidence in the marker:
298+
299+
```json
300+
{
301+
"loaded-at": 1781923400,
302+
"loaded-path": ".../plugin_data/EasyLibrary/packages/libform/2.0.0"
303+
}
304+
```
305+
306+
When EasyLibrary refreshes hidden proxy mounts, it preserves those runtime
307+
fields instead of erasing them. The package status service now clears
308+
restart-required metadata only when both checks are true:
309+
310+
- the active runtime path matches the staged package path;
311+
- the runtime loaded at or after the staged install/update/rollback timestamp.
312+
313+
This avoids two bad states:
314+
315+
- false pending restart after a successful restart;
316+
- false active state when a new package target was staged after the currently
317+
loaded runtime.
318+
319+
Validation performed with `libform`:
320+
321+
```text
322+
/easylibrary packages install libform stable confirm
323+
restart
324+
/easylibrary packages status libform
325+
libform / LibForm v2.0.0 [active]
326+
- Restart-required markers were cleared because the package is active.
327+
328+
/easylibrary packages doctor
329+
Packages: 13 / installed: 13 / active: 13 / pending restart: 0
330+
Problems: 0
331+
332+
elprobe run libform
333+
functional=yes source=package-backed-plugin
334+
```
335+
336+
### Dependency-safe removal
337+
338+
Remove is now dependency-aware for installed packages.
339+
340+
Example:
341+
342+
```text
343+
/easylibrary packages remove libpacket
344+
```
345+
346+
If `libcommand` is still installed and requires `LibPacket`, the command is
347+
blocked with a concrete reason:
348+
349+
```text
350+
Cannot remove while installed packages require it: LibCommand.
351+
Remove or migrate those dependent packages first, then retry this command.
352+
```
353+
354+
Why this matters:
355+
356+
- `remove` is restart-based, so breaking the next boot is easy if a dependency
357+
disappears silently;
358+
- package-backed plugins participate in PMMP `depend` resolution;
359+
- the package manager must refuse an unsafe detach before writing state.
360+
361+
This guard is intentionally conservative. It checks installed package manifests
362+
and blocks removal when another installed package declares the target as a hard
363+
dependency.
364+
365+
### Temporary cleanup allowlist
366+
367+
Cleanup was narrowed to package-manager temporary paths only.
368+
369+
It now targets:
370+
371+
```text
372+
.downloads
373+
.staging
374+
_incoming*
375+
.incoming*
376+
```
377+
378+
It intentionally preserves:
379+
380+
```text
381+
packages/<id>/<version>/
382+
.backups/
383+
```
384+
385+
Why this matters:
386+
387+
- installed package versions are active or rollback candidates;
388+
- `.backups` is part of rollback safety;
389+
- interrupted downloads/staging folders should be disposable;
390+
- accidental broad cleanup should not remove usable packages.
391+
392+
Validation:
393+
394+
```text
395+
/easylibrary packages cleanup
396+
/easylibrary packages cleanup confirm
397+
```
398+
399+
The dry-run and apply output both mention the allowlist and state that installed
400+
package versions and backups are preserved.
401+
274402
### Release asset install validation
275403

276404
The full package-backed server was rebuilt from official GitHub Release
@@ -955,6 +1083,7 @@ Cleanup currently removes temporary package manager data only:
9551083
.downloads
9561084
.staging
9571085
_incoming*
1086+
.incoming*
9581087
```
9591088

9601089
Installed package versions and `.backups` are preserved because backups are part

src/imperazim/library/command/subcommand/packages/DoctorPackagesSubCommand.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ protected function run(CommandResult $result): void {
5757
foreach ($doctor['statuses'] as $status) {
5858
if ($status->getState() !== PackageInstallState::INSTALLED_SHADOWED_BY_STANDALONE
5959
&& $status->getState() !== PackageInstallState::INSTALLED_DISABLED
60+
&& $status->getState() !== PackageInstallState::INSTALLED_PENDING_RESTART
6061
&& $status->getState() !== PackageInstallState::ACTIVE) {
6162
continue;
6263
}

src/imperazim/library/package/PackageAutoloadManager.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ public function enableInstalledPackages(): void {
7676

7777
try {
7878
$this->enablePackage($source, $path);
79-
PackageRuntimeRegistry::markActive($source->getId());
79+
PackageRuntimeRegistry::markActive($source->getId(), $path);
8080
$this->host->getLogger()->info('[PackageManager] Internal package active: ' . $source->getName() . ' v' . $status->getInstalledVersion());
8181
} catch (Throwable $throwable) {
8282
$this->unregisterPackageAutoloaders($source->getId());

src/imperazim/library/package/PackageCleanupService.php

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,11 @@ public function cleanupTemporary(bool $confirmed): PackageCleanupReport {
5353
}
5454

5555
if (is_dir($target)) {
56-
$this->removeDirectoryContents($target);
56+
if ($this->isIncomingRelativePath($label)) {
57+
$this->removeDirectory($target);
58+
} else {
59+
$this->removeDirectoryContents($target);
60+
}
5761
} else {
5862
@unlink($target);
5963
}
@@ -63,6 +67,7 @@ public function cleanupTemporary(bool $confirmed): PackageCleanupReport {
6367
if (!$report->hasChanges()) {
6468
$report->addNote('No temporary package downloads, staging folders or _incoming artifacts were found.');
6569
}
70+
$report->addNote('Cleanup allowlist: .downloads, .staging, _incoming* and .incoming* only.');
6671
$report->addNote('Installed package versions and .backups are intentionally preserved.');
6772

6873
return $report;
@@ -121,7 +126,11 @@ private function isSafeCleanupTarget(string $packagesDirectory, string $target):
121126
$relative = $this->relativePath($root, $path);
122127
return $relative === '.downloads'
123128
|| $relative === '.staging'
124-
|| str_starts_with($relative, '_incoming')
129+
|| $this->isIncomingRelativePath($relative);
130+
}
131+
132+
private function isIncomingRelativePath(string $relative): bool {
133+
return str_starts_with($relative, '_incoming')
125134
|| str_starts_with($relative, '.incoming');
126135
}
127136

@@ -174,4 +183,9 @@ private function removeDirectoryContents(string $path): void {
174183
@unlink($file->getPathname());
175184
}
176185
}
186+
187+
private function removeDirectory(string $path): void {
188+
$this->removeDirectoryContents($path);
189+
@rmdir($path);
190+
}
177191
}

src/imperazim/library/package/PackagePluginLoader.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ public function loadPlugin(string $file): void {
4444
}
4545

4646
$this->registerPackageSourcePath($source, $path);
47-
PackageRuntimeRegistry::markActive($source->getId());
47+
PackageRuntimeRegistry::markActive($source->getId(), $path);
4848
}
4949

5050
public function getPluginDescription(string $file): ?PluginDescription {

src/imperazim/library/package/PackagePluginMountManager.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use function is_array;
1919
use function is_dir;
2020
use function is_file;
21+
use function is_numeric;
2122
use function is_string;
2223
use function json_decode;
2324
use function json_encode;
@@ -330,6 +331,7 @@ private function writeMount(PackageSource $source, string $packagePath, string $
330331
throw new RuntimeException('Cannot create plugin mount directory: ' . $mount);
331332
}
332333

334+
$previousMarker = $this->readMarker($mount);
333335
$manifest = $this->readManifest($packagePath);
334336
$manifest->assertMatches($source, $version);
335337

@@ -358,6 +360,7 @@ private function writeMount(PackageSource $source, string $packagePath, string $
358360
'manifest-state' => $standalonePresent ? 'parked-standalone-present' : 'active',
359361
'managed-by' => 'EasyLibrary',
360362
];
363+
$this->preserveRuntimeMarker($previousMarker, $payload);
361364
file_put_contents($mount . DIRECTORY_SEPARATOR . self::MARKER_FILE, json_encode($payload, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n");
362365

363366
$readyManifest = $mount . DIRECTORY_SEPARATOR . self::READY_PLUGIN_YML;
@@ -381,6 +384,30 @@ private function writeMount(PackageSource $source, string $packagePath, string $
381384
}
382385
}
383386

387+
/**
388+
* Proxy plugins are loaded before EasyLibrary can refresh hidden mounts.
389+
* Keep the runtime marker from that early proxy load so status/doctor can
390+
* decide whether a staged package already took effect after restart.
391+
*
392+
* @param array<string, mixed>|null $previousMarker
393+
* @param array<string, mixed> $payload
394+
*/
395+
private function preserveRuntimeMarker(?array $previousMarker, array &$payload): void {
396+
if ($previousMarker === null || !is_numeric($previousMarker['loaded-at'] ?? null)) {
397+
return;
398+
}
399+
400+
$payload['loaded-at'] = (int) $previousMarker['loaded-at'];
401+
if (is_string($previousMarker['loaded-path'] ?? null)) {
402+
$payload['loaded-path'] = $previousMarker['loaded-path'];
403+
return;
404+
}
405+
406+
if (is_string($previousMarker['path'] ?? null)) {
407+
$payload['loaded-path'] = $previousMarker['path'];
408+
}
409+
}
410+
384411
private function readManifest(string $packagePath): PackageManifest {
385412
$path = $packagePath . DIRECTORY_SEPARATOR . 'package.yml';
386413
if (!is_file($path)) {
@@ -515,6 +542,7 @@ private function proxyClassTemplate(): string {
515542
use function is_file;
516543
use function is_string;
517544
use function json_decode;
545+
use function json_encode;
518546
use function ltrim;
519547
use function method_exists;
520548
use function rtrim;
@@ -526,7 +554,10 @@ private function proxyClassTemplate(): string {
526554
use function str_starts_with;
527555
use function strlen;
528556
use function substr;
557+
use function time;
529558
use function trim;
559+
use const JSON_PRETTY_PRINT;
560+
use const JSON_THROW_ON_ERROR;
530561
531562
final class __PROXY_CLASS__ extends PluginBase {
532563
@@ -587,6 +618,10 @@ private function bootstrap(): void {
587618
throw new RuntimeException('EasyLibrary package path is missing or unavailable: ' . $packagePath);
588619
}
589620
621+
$data['loaded-at'] = time();
622+
$data['loaded-path'] = $packagePath;
623+
file_put_contents($markerPath, json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . "\n");
624+
590625
$this->marker = $data;
591626
$this->registerPackageAutoloaders($packagePath, $data);
592627
$this->entry = $this->createEntry($data);

src/imperazim/library/package/PackageRemoveService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ public function remove(PackageSource $source, bool $confirmed): PackageRemoveRep
3030
if ($state !== []) {
3131
$requiredDependents = $this->installedDependents($source, false);
3232
if ($requiredDependents !== []) {
33-
$report->block('Cannot remove while installed packages require it: ' . implode(', ', $requiredDependents) . '. Remove dependent packages first.');
33+
$report->block('Cannot remove while installed packages require it: ' . implode(', ', $requiredDependents) . '. Remove or migrate those dependent packages first, then retry this command.');
3434
return $report;
3535
}
3636

0 commit comments

Comments
 (0)