Skip to content

Commit 101b8f9

Browse files
committed
Handle extension-already-enabled output inside installer plugin
Rather than in bin/compile's attempt-native-installs-of-polyfill-provided-packages loop, we can figure out if a requested package doesn't actually trigger an install event inside the plugin. This can happen when a polyfill declares that it provides a particular package, but that package is already there (e.g. because the extension is bundled with PHP and always enabled). We previously had to handle this on the caller side in bin/compile by tee-ing the installer output to a special file we'd inspect afterwards, since a successful install of a ".native" variant of a package and a "nothing to install or update" outcome both resulted in a 0 exit status, so we could only tell the two apart by checking the output. GUS-W-18557233
1 parent 8185b46 commit 101b8f9

File tree

3 files changed

+60
-20
lines changed

3 files changed

+60
-20
lines changed

bin/compile

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -512,14 +512,10 @@ else
512512
EOF
513513
fi
514514

515-
# close display output FD (we make new ones for each iteration next)
516-
exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}>&-
517-
518515
export -n providedextensionslog_file_path # export no longer needed
519516
if [[ -s "$providedextensionslog_file_path" ]]; then
520517
notice_inline "detected userland polyfill packages for PHP extensions"
521518
notice_inline "now attempting to install native extension packages"
522-
current_install_log=$(mktemp -t "currentinstall.log.XXXXXX" -u) # for capturing the single "composer install" output
523519
# the platform installer recorded userland packages that "provide" a PHP extension
524520
# for each occurrence (source package providing other package(s)) in the list,
525521
# we will now attempt to install the real native extension packages (rest of the line)
@@ -531,21 +527,14 @@ if [[ -s "$providedextensionslog_file_path" ]]; then
531527
ext_package=$(cut -d":" -f1 <<< "$extension") # extract name from "ext-foo:1.2.3" string
532528
ext_name=${ext_package#"heroku-sys/"} # no "heroku-sys/" prefix for human output
533529
# run a `composer require`, confined to the package we're attempting, allowing any version
534-
# we're appending to install.log using tee, and using another new "display output" FD to redirect to a new single-step log (tee truncates for us)
535-
# this log is checked later to see if a package install actually happened, or if it was already enabled (since both exit status 0)
536-
exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}> >(tee "$current_install_log")
530+
# (the .native "replace"d variant in each extension matches the regular version, so the constraint for that regular version is enough)
537531
# NO_COLOR=1 suppresses the download progress meter whose ANSI cursor codes trip up output in Heroku Dashboard
538-
if NO_COLOR=1 composer require -d "$build_dir/.heroku/php" "${ext_package}.native:*" >> $build_dir/.heroku/php/install.log 2>&1; then
539-
# package added successfully
540-
[[ -s "$current_install_log" ]] || { echo "- ${ext_name} (already enabled)" | indent; } # if nothing was actually installed above, that means the dependency was already satisfied (i.e. extension is statically built into PHP); we need to echo something to that effect
541-
else
532+
if ! NO_COLOR=1 composer require -d "$build_dir/.heroku/php" "${ext_package}.native:*" >> $build_dir/.heroku/php/install.log 2>&1; then
542533
# composer did not succeed; this means no package that matches all existing package's requirements was found
543534
notice_inline "no suitable native version of ${ext_name} available"
544535
fi
545-
exec {PHP_PLATFORM_INSTALLER_DISPLAY_OUTPUT_FDNO}>&-
546536
done
547537
done {fd_num}< "$providedextensionslog_file_path" # use bash 4.1+ automatic file descriptor allocation (better than hardcoding e.g. "3<"), otherwise this loop's stdin (the lines of the file) will be consumed by programs called inside the loop
548-
rm "$current_install_log"
549538
exec {fd_num}>&-
550539
fi
551540

support/installer/src/ComposerInstaller.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ public function getInstallPath(PackageInterface $package)
1616

1717
public static function formatHerokuSysName(string $name): string
1818
{
19-
return sscanf($name, "heroku-sys/%s")[0] ?? $name;
19+
// strip a "heroku-sys/" prefix if it exists, and in that case, also a ".native" postfix
20+
// this turns our internal "heroku-sys/ext-foobar.native" or "heroku-sys/php" names into "ext-foobar" or "php" for display output
21+
return preg_replace('#^(heroku-sys/)(.+?)(?(1).native)?$#', '$2', $name);
2022
}
2123

2224
/**

support/installer/src/ComposerInstallerPlugin.php

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
use Symfony\Component\Console\Helper\{HelperSet,ProgressBar};
99
use Symfony\Component\Console\Input\ArrayInput;
1010
use Symfony\Component\Console\Output\StreamOutput;
11-
use Composer\Plugin\{PluginEvents,PluginInterface,PreFileDownloadEvent,PostFileDownloadEvent};
11+
use Composer\Plugin\{PluginEvents,PluginInterface,PreFileDownloadEvent,PostFileDownloadEvent,PrePoolCreateEvent};
1212
use Composer\EventDispatcher\EventSubscriberInterface;
13-
use Composer\Installer\{PackageEvent,PackageEvents};
13+
use Composer\Installer\{InstallerEvent,InstallerEvents,PackageEvent,PackageEvents};
1414
use Composer\Package\PackageInterface;
1515
use Composer\Util\Filesystem;
1616

@@ -120,14 +120,57 @@ public function uninstall(Composer $composer, IOInterface $io)
120120
public static function getSubscribedEvents()
121121
{
122122
return [
123+
PluginEvents::PRE_POOL_CREATE => 'onPrePoolCreate',
124+
InstallerEvents::PRE_OPERATIONS_EXEC => 'onPreOperationsExec',
123125
PluginEvents::PRE_FILE_DOWNLOAD => 'onPreFileDownload',
124126
PluginEvents::POST_FILE_DOWNLOAD => 'onPostFileDownload',
125127
PackageEvents::PRE_PACKAGE_INSTALL => 'onPrePackageInstall',
126128
PackageEvents::POST_PACKAGE_INSTALL => 'onPostPackageInstall',
127129
];
128130
}
129131

130-
// this fires when a download starts
132+
// This does not fire on initial install, as the plugin gets installed as part of that, but the event fires before the plugin install.
133+
// Just what we want, since the logic in here is for the "ext-foobar.native" install attempts after the main packages installation.
134+
// For those invocations, the plugin is already enabled, and this event handler fires.
135+
public function onPrePoolCreate(PrePoolCreateEvent $event)
136+
{
137+
// the list of explicitly requested packages from e.g. a 'composer require ext-foobar.native:*'
138+
// we remember this for later, so we can output a message about already-enabled extensions
139+
// this will be e.g. ["heroku-sys/ext-mbstring.native"]
140+
$this->requestedPackages = $event->getRequest()->getUpdateAllowList();
141+
}
142+
143+
// This does not fire on initial install, as the plugin gets installed as part of that, but the event fires before the plugin install.
144+
// Just what we want, since the logic in here is for the "ext-foobar.native" install attempts after the main packages installation.
145+
// For those invocations, the plugin is already enabled, and this event handler fires.
146+
public function onPreOperationsExec(InstallerEvent $event)
147+
{
148+
// From the list of operations, we are getting all packages due for install.
149+
// For each package, we check the "replaces" declarations.
150+
// For instance, "heroku-sys/ext-mbstring" will declare that it replaces "heroku-sys/ext-mbstring.native".
151+
// The "replaces" array is keyed by "replacement destination", so it's e.g.:
152+
// {"heroku-sys/ext-mbstring.native": {Composer\Package\Link(source="heroku-sys/ext-mbstring",target="heroku-sys/ext-mbstring.native")}}
153+
// For any package found here that is in the requestAllowList made in onPrePoolCreate, this means a regular installation,
154+
// the Downloader will print install progress in this case.
155+
// What we are really looking for, though, is packages in requestAllowList that are not in our list of operations,
156+
// that means the extension is already enabled (either installed previously, or enabled in PHP by default).
157+
// Because no installer event will fire in that case (nothing gets installed, after all), we want to output a message here.
158+
$installs = [];
159+
foreach($event->getTransaction()->getOperations() as $operation) {
160+
// add the package itself, just for completeness
161+
if ($operation->getOperationType() == "install") {
162+
$installs[] = $operation->getPackage()->getPrettyName();
163+
}
164+
// add all "replace" declarations from the package
165+
$installs = array_merge($installs, array_keys($operation->getPackage()->getReplaces()));
166+
}
167+
foreach(array_diff($this->requestedPackages, $installs) as $requestedPackageNotInstalled) {
168+
$this->displayIo->write(sprintf('<indent>-</indent> <info>%s</info> (already enabled)', ComposerInstaller::formatHerokuSysName($requestedPackageNotInstalled)));
169+
}
170+
}
171+
172+
// Because our plugin declares "plugin-modifies-downloads", Composer installs it first.
173+
// After that, all other package downloads trigger this event.
131174
public function onPreFileDownload(PreFileDownloadEvent $event)
132175
{
133176
$package = $event->getContext();
@@ -146,7 +189,9 @@ public function onPreFileDownload(PreFileDownloadEvent $event)
146189
}
147190
}
148191

149-
// this fires when a download finishes, so we can output progress info
192+
// Because our plugin declares "plugin-modifies-downloads", Composer installs it first.
193+
// After that, all other package downloads trigger this event.
194+
// We use it to output progress info when a download finishes
150195
// (Downloader::download returns a promise for parallel downloads, so would be as useless as onPreFileDownload above)
151196
public function onPostFileDownload(PostFileDownloadEvent $event)
152197
{
@@ -158,8 +203,9 @@ public function onPostFileDownload(PostFileDownloadEvent $event)
158203
}
159204
}
160205

161-
// this fires for every package install
162-
// nothing to do for us here except to clear a progress bar if it exists
206+
// Because our plugin declares "plugin-modifies-install-path", Composer installs it first.
207+
// After that, all other package installs trigger this event.
208+
// Nothing to do for us here except to clear a progress bar if it exists
163209
public function onPrePackageInstall(PackageEvent $event)
164210
{
165211
// clear our progress bar, since we're done with downloads
@@ -176,6 +222,9 @@ public function onPrePackageInstall(PackageEvent $event)
176222
}
177223
}
178224

225+
// Because our plugin declares "plugin-modifies-install-path", Composer installs it first.
226+
// After that, all other package installs trigger this event.
227+
// Here we do a lot of the "heavy lifting"
179228
public function onPostPackageInstall(PackageEvent $event)
180229
{
181230
// first, load all platform requirements from all operations

0 commit comments

Comments
 (0)