Skip to content

Commit 43c4232

Browse files
authored
Allow to asynchronously generate PDFs (#65)
The current implementation of `HeadlessChrome::toPdf()` always assumes that it controls the event loop instance, i.e. `HeadlessChrome` creates and starts the event loop manually. This may work for most use cases as they are mostly triggered via Icinga Web, but not if you want to generate PDFs using a daemon. Since our scheduler uses the same global event instance, it is unfavourable to call `Factory::create()` over again occasionally. refs Icinga/icingaweb2-module-reporting#229
2 parents 91529dd + 8e7ff15 commit 43c4232

File tree

3 files changed

+185
-104
lines changed

3 files changed

+185
-104
lines changed

library/Pdfexport/HeadlessChrome.php

Lines changed: 144 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
use ipl\Html\HtmlString;
1313
use LogicException;
1414
use React\ChildProcess\Process;
15-
use React\EventLoop\Factory;
15+
use React\EventLoop\Loop;
1616
use React\EventLoop\TimerInterface;
17+
use React\Promise;
18+
use React\Promise\ExtendedPromiseInterface;
19+
use Throwable;
1720
use WebSocket\Client;
1821
use WebSocket\ConnectionException;
1922

@@ -240,111 +243,168 @@ public function fromHtml($html, $asFile = false)
240243
}
241244

242245
/**
243-
* Export to PDF
246+
* Generate a PDF raw string asynchronously.
244247
*
245-
* @return string
246-
* @throws Exception
248+
* @return ExtendedPromiseInterface
247249
*/
248-
public function toPdf()
250+
public function asyncToPdf(): ExtendedPromiseInterface
249251
{
250-
switch (true) {
251-
case $this->remote !== null:
252-
try {
253-
$result = $this->jsonVersion($this->remote[0], $this->remote[1]);
254-
$parts = explode('/', $result['webSocketDebuggerUrl']);
255-
$pdf = $this->printToPDF(
256-
join(':', $this->remote),
257-
end($parts),
258-
! $this->document->isEmpty() ? $this->document->getPrintParameters() : []
259-
);
260-
break;
261-
} catch (Exception $e) {
262-
if ($this->binary === null) {
263-
throw $e;
264-
} else {
252+
$deferred = new Promise\Deferred();
253+
Loop::futureTick(function () use ($deferred) {
254+
switch (true) {
255+
case $this->remote !== null:
256+
try {
257+
$result = $this->jsonVersion($this->remote[0], $this->remote[1]);
258+
if (is_array($result)) {
259+
$parts = explode('/', $result['webSocketDebuggerUrl']);
260+
$pdf = $this->printToPDF(
261+
join(':', $this->remote),
262+
end($parts),
263+
! $this->document->isEmpty() ? $this->document->getPrintParameters() : []
264+
);
265+
break;
266+
}
267+
} catch (Exception $e) {
268+
if ($this->binary == null) {
269+
$deferred->reject($e);
270+
return;
271+
}
272+
265273
Logger::warning(
266274
'Failed to connect to remote chrome: %s:%d (%s)',
267275
$this->remote[0],
268276
$this->remote[1],
269277
$e
270278
);
271279
}
272-
}
273280

274-
// Fallback to the local binary if a remote chrome is unavailable
275-
case $this->binary !== null:
276-
$browserHome = $this->getFileStorage()->resolvePath('HOME');
277-
$commandLine = join(' ', [
278-
escapeshellarg($this->getBinary()),
279-
static::renderArgumentList([
280-
'--bwsi',
281-
'--headless',
282-
'--disable-gpu',
283-
'--no-sandbox',
284-
'--no-first-run',
285-
'--disable-dev-shm-usage',
286-
'--remote-debugging-port=0',
287-
'--homedir=' => $browserHome,
288-
'--user-data-dir=' => $browserHome
289-
])
290-
]);
291-
292-
if (Platform::isLinux()) {
293-
Logger::debug('Starting browser process: HOME=%s exec %s', $browserHome, $commandLine);
294-
$chrome = new Process('exec ' . $commandLine, null, ['HOME' => $browserHome]);
295-
} else {
296-
Logger::debug('Starting browser process: %s', $commandLine);
297-
$chrome = new Process($commandLine);
298-
}
281+
// Reject the promise if we didn't get the expected output from the /json/version endpoint.
282+
if ($this->binary === null) {
283+
$deferred->reject(
284+
new Exception('Failed to determine remote chrome version via the /json/version endpoint.')
285+
);
286+
return;
287+
}
299288

300-
$loop = Factory::create();
289+
// Fallback to the local binary if a remote chrome is unavailable
290+
case $this->binary !== null:
291+
$browserHome = $this->getFileStorage()->resolvePath('HOME');
292+
$commandLine = join(' ', [
293+
escapeshellarg($this->getBinary()),
294+
static::renderArgumentList([
295+
'--bwsi',
296+
'--headless',
297+
'--disable-gpu',
298+
'--no-sandbox',
299+
'--no-first-run',
300+
'--disable-dev-shm-usage',
301+
'--remote-debugging-port=0',
302+
'--homedir=' => $browserHome,
303+
'--user-data-dir=' => $browserHome
304+
])
305+
]);
306+
307+
if (Platform::isLinux()) {
308+
Logger::debug('Starting browser process: HOME=%s exec %s', $browserHome, $commandLine);
309+
$chrome = new Process('exec ' . $commandLine, null, ['HOME' => $browserHome]);
310+
} else {
311+
Logger::debug('Starting browser process: %s', $commandLine);
312+
$chrome = new Process($commandLine);
313+
}
301314

302-
$killer = $loop->addTimer(10, function (TimerInterface $timer) use ($chrome) {
303-
$chrome->terminate(6); // SIGABRT
304-
Logger::error(
305-
'Terminated browser process after %d seconds elapsed without the expected output',
306-
$timer->getInterval()
307-
);
308-
});
315+
$killer = Loop::addTimer(10, function (TimerInterface $timer) use ($chrome, $deferred) {
316+
$chrome->terminate(6); // SIGABRT
309317

310-
$chrome->start($loop);
318+
Logger::error(
319+
'Browser timed out after %d seconds without the expected output',
320+
$timer->getInterval()
321+
);
311322

312-
$pdf = null;
313-
$chrome->stderr->on('data', function ($chunk) use (&$pdf, $chrome, $loop, $killer) {
314-
Logger::debug('Caught browser output: %s', $chunk);
323+
$deferred->reject(
324+
new Exception(
325+
'Received empty response or none at all from browser.'
326+
. ' Please check the logs for further details.'
327+
)
328+
);
329+
});
330+
331+
$chrome->start();
332+
333+
$chrome->stderr->on('data', function ($chunk) use ($chrome, $deferred, $killer) {
334+
Logger::debug('Caught browser output: %s', $chunk);
335+
336+
if (preg_match(self::DEBUG_ADDR_PATTERN, trim($chunk), $matches)) {
337+
Loop::cancelTimer($killer);
338+
339+
try {
340+
$pdf = $this->printToPDF(
341+
$matches[1],
342+
$matches[2],
343+
! $this->document->isEmpty() ? $this->document->getPrintParameters() : []
344+
);
345+
} catch (Exception $e) {
346+
Logger::error('Failed to print PDF. An error occurred: %s', $e);
347+
}
348+
349+
$chrome->terminate();
350+
351+
if (! empty($pdf)) {
352+
$deferred->resolve($pdf);
353+
} else {
354+
$deferred->reject(
355+
new Exception(
356+
'Received empty response or none at all from browser.'
357+
. ' Please check the logs for further details.'
358+
)
359+
);
360+
}
361+
}
362+
});
315363

316-
if (preg_match(self::DEBUG_ADDR_PATTERN, trim($chunk), $matches)) {
317-
$loop->cancelTimer($killer);
364+
$chrome->on('exit', function ($exitCode, $signal) use ($killer) {
365+
Loop::cancelTimer($killer);
318366

319-
try {
320-
$pdf = $this->printToPDF(
321-
$matches[1],
322-
$matches[2],
323-
! $this->document->isEmpty() ? $this->document->getPrintParameters() : []
324-
);
325-
} catch (Exception $e) {
326-
Logger::error('Failed to print PDF. An error occurred: %s', $e);
327-
}
367+
Logger::debug('Browser terminated by signal %d and exited with code %d', $signal, $exitCode);
328368

329-
$chrome->terminate();
330-
}
331-
});
369+
// Browser is either timed out (after 10s) and the promise should have already been rejected,
370+
// or it is terminated using its terminate() method, in which case the promise is also already
371+
// resolved/rejected. So, we don't need to resolve/reject the promise here.
372+
});
332373

333-
$chrome->on('exit', function ($exitCode, $termSignal) use ($loop, $killer) {
334-
$loop->cancelTimer($killer);
374+
return;
375+
}
335376

336-
Logger::debug('Browser terminated by signal %d and exited with code %d', $termSignal, $exitCode);
337-
});
377+
if (! empty($pdf)) {
378+
$deferred->resolve($pdf);
379+
} else {
380+
$deferred->reject(
381+
new Exception(
382+
'Received empty response or none at all from browser.'
383+
. ' Please check the logs for further details.'
384+
)
385+
);
386+
}
387+
});
338388

339-
$loop->run();
340-
}
389+
return $deferred->promise();
390+
}
341391

342-
if (empty($pdf)) {
343-
throw new Exception(
344-
'Received empty response or none at all from browser.'
345-
. ' Please check the logs for further details.'
346-
);
347-
}
392+
/**
393+
* Export to PDF
394+
*
395+
* @return string
396+
* @throws Exception
397+
*/
398+
public function toPdf()
399+
{
400+
$pdf = '';
401+
// We don't intend to register any then/otherwise handlers, so call done on that promise
402+
// to properly propagate unhandled exceptions to the caller.
403+
$this->asyncToPdf()->done(function (string $newPdf) use (&$pdf) {
404+
$pdf = $newPdf;
405+
});
406+
407+
Loop::run();
348408

349409
return $pdf;
350410
}

library/Pdfexport/ProvidedHook/Pdfexport.php

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Icinga\Module\Pdfexport\PrintableHtmlDocument;
1515
use iio\libmergepdf\Driver\TcpdiDriver;
1616
use iio\libmergepdf\Merger;
17+
use React\Promise\ExtendedPromiseInterface;
1718

1819
class Pdfexport extends PdfexportHook
1920
{
@@ -86,43 +87,53 @@ public function htmlToPdf($html)
8687
)
8788
->toPdf();
8889

89-
$merger = new Merger(new TcpdiDriver());
90-
$merger->addRaw($coverPagePdf);
91-
$merger->addRaw($pdf);
92-
93-
$pdf = $merger->merge();
90+
$pdf = $this->mergePdfs($coverPagePdf, $pdf);
9491
}
9592

9693
return $pdf;
9794
}
9895

99-
public function streamPdfFromHtml($html, $filename)
96+
/**
97+
* Transforms the given printable html document/string asynchronously to PDF.
98+
*
99+
* @param PrintableHtmlDocument|string $html
100+
*
101+
* @return ExtendedPromiseInterface
102+
*/
103+
public function asyncHtmlToPdf($html): ExtendedPromiseInterface
100104
{
101-
$filename = basename($filename, '.pdf') . '.pdf';
102-
103105
// Keep reference to the chrome object because it is using temp files which are automatically removed when
104106
// the object is destructed
105107
$chrome = $this->chrome();
106108

107-
$pdf = $chrome->fromHtml($html, static::getForceTempStorage())->toPdf();
109+
$pdfPromise = $chrome->fromHtml($html, static::getForceTempStorage())->asyncToPdf();
108110

109111
if ($html instanceof PrintableHtmlDocument && ($coverPage = $html->getCoverPage()) !== null) {
110-
$coverPagePdf = $chrome
111-
->fromHtml(
112+
/** @var ExtendedPromiseInterface $pdfPromise */
113+
$pdfPromise = $pdfPromise->then(function (string $pdf) use ($chrome, $html, $coverPage) {
114+
return $chrome->fromHtml(
112115
(new PrintableHtmlDocument())
113116
->add($coverPage)
114117
->addAttributes($html->getAttributes())
115118
->removeMargins(),
116119
static::getForceTempStorage()
117-
)
118-
->toPdf();
120+
)->asyncToPdf()->then(
121+
function (string $coverPagePdf) use ($pdf) {
122+
return $this->mergePdfs($coverPagePdf, $pdf);
123+
}
124+
);
125+
});
126+
}
119127

120-
$merger = new Merger(new TcpdiDriver());
121-
$merger->addRaw($coverPagePdf);
122-
$merger->addRaw($pdf);
128+
return $pdfPromise;
129+
}
123130

124-
$pdf = $merger->merge();
125-
}
131+
public function streamPdfFromHtml($html, $filename)
132+
{
133+
$filename = basename($filename, '.pdf') . '.pdf';
134+
135+
// Generate the PDF before changing the response headers to properly handle and display errors in the UI.
136+
$pdf = $this->htmlToPdf($html);
126137

127138
/** @var Web $app */
128139
$app = Icinga::app();
@@ -151,4 +162,14 @@ protected function chrome()
151162

152163
return $chrome;
153164
}
165+
166+
protected function mergePdfs(string ...$pdfs): string
167+
{
168+
$merger = new Merger(new TcpdiDriver());
169+
foreach ($pdfs as $pdf) {
170+
$merger->addRaw($pdf);
171+
}
172+
173+
return $merger->merge();
174+
}
154175
}

phpstan-baseline.neon

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,12 @@ parameters:
6666
path: library/Pdfexport/HeadlessChrome.php
6767

6868
-
69-
message: "#^Cannot access offset 'webSocketDebuggerUrl' on array\\|bool\\.$#"
69+
message: "#^Cannot call method on\\(\\) on React\\\\Stream\\\\ReadableStreamInterface\\|React\\\\Stream\\\\WritableStreamInterface\\|null\\.$#"
7070
count: 1
7171
path: library/Pdfexport/HeadlessChrome.php
7272

7373
-
74-
message: "#^Cannot call method on\\(\\) on React\\\\Stream\\\\ReadableStreamInterface\\|React\\\\Stream\\\\WritableStreamInterface\\|null\\.$#"
74+
message: "#^Method Icinga\\\\Module\\\\Pdfexport\\\\HeadlessChrome\\:\\:asyncToPdf\\(\\) should return React\\\\Promise\\\\ExtendedPromiseInterface but returns React\\\\Promise\\\\PromiseInterface\\.$#"
7575
count: 1
7676
path: library/Pdfexport/HeadlessChrome.php
7777

0 commit comments

Comments
 (0)