• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

PHPOffice / PhpSpreadsheet / 28774268733

06 Jul 2026 07:10AM UTC coverage: 97.157% (-0.01%) from 97.167%
28774268733

Pull #4834

github

web-flow
Merge 22dd8078e into 0577c7488
Pull Request #4834: Add parallel Xlsx writing via pcntl_fork

189 of 200 new or added lines in 6 files covered. (94.5%)

48553 of 49974 relevant lines covered (97.16%)

385.29 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.94
/src/PhpSpreadsheet/Parallel/Backend/PcntlBackend.php
1
<?php
2

3
namespace PhpOffice\PhpSpreadsheet\Parallel\Backend;
4

5
use Closure;
6
use PhpOffice\PhpSpreadsheet\Exception;
7
use Throwable;
8

9
class PcntlBackend implements BackendInterface
10
{
11
    /** Seconds a child may run before being terminated; 0 means no time limit. */
12
    private const DEFAULT_TIMEOUT = 0;
13

14
    /** Seconds to wait after SIGTERM before escalating to SIGKILL. */
15
    private const SIGTERM_GRACE_SECONDS = 2;
16

17
    private int $timeout;
18

19
    public function __construct(int $timeout = self::DEFAULT_TIMEOUT)
18✔
20
    {
21
        $this->timeout = $timeout;
18✔
22
    }
23

24
    public function execute(array $tasks, Closure $worker, int $maxWorkers): array
16✔
25
    {
26
        if (!self::isAvailable()) {
16✔
27
            throw new Exception('pcntl extension is not available'); // @codeCoverageIgnore
28
        }
29

30
        $taskCount = count($tasks);
16✔
31
        $results = array_fill(0, $taskCount, null);
16✔
32
        $tempFiles = [];
16✔
33
        $pids = [];
16✔
34
        $reaped = [];
16✔
35

36
        try {
37
            // Process tasks in batches of maxWorkers
38
            for ($batchStart = 0; $batchStart < $taskCount; $batchStart += $maxWorkers) {
16✔
39
                $batchEnd = min($batchStart + $maxWorkers, $taskCount);
16✔
40
                $batchPids = [];
16✔
41

42
                // Fork children for this batch
43
                for ($i = $batchStart; $i < $batchEnd; ++$i) {
16✔
44
                    $tempFile = tempnam(sys_get_temp_dir(), 'phpspreadsheet_parallel_');
16✔
45
                    if ($tempFile === false) {
16✔
46
                        throw new Exception('Failed to create temp file for parallel execution'); // @codeCoverageIgnore
47
                    }
48
                    $tempFiles[$i] = $tempFile;
16✔
49

50
                    $pid = pcntl_fork();
16✔
51
                    if ($pid === -1) {
16✔
52
                        throw new Exception('Failed to fork process'); // @codeCoverageIgnore
53
                    }
54

55
                    if ($pid === 0) {
16✔
56
                        // Child process — coverage cannot be collected from forked children
57
                        // @codeCoverageIgnoreStart
58
                        try {
59
                            $result = $worker($tasks[$i]);
60
                            self::writeChildResult($tempFile, ['ok' => true, 'result' => $result]);
61
                        } catch (Throwable $e) {
62
                            self::writeChildResult($tempFile, ['ok' => false, 'error' => ParallelTaskError::fromThrowable($e)]);
63
                        }
64
                        self::exitChild();
65
                        // @codeCoverageIgnoreEnd
66
                    }
67

68
                    // Parent process
69
                    $pids[$i] = $pid;
16✔
70
                    $batchPids[$i] = $pid;
16✔
71
                }
72

73
                // Wait for all children in this batch
74
                $statuses = [];
16✔
75
                foreach ($batchPids as $i => $pid) {
16✔
76
                    $statuses[$i] = $this->waitForChild($pid);
16✔
77
                    $reaped[$pid] = true;
15✔
78
                }
79

80
                // Collect results for this batch
81
                foreach ($batchPids as $i => $pid) {
15✔
82
                    $results[$i] = $this->collectResult($i, $tempFiles[$i], $statuses[$i]);
15✔
83
                }
84
            }
85
        } finally {
86
            // Children never reach this block — exitChild() terminates them —
87
            // so only the parent reaps and cleans up here. Children still
88
            // running (e.g. siblings of a timed-out task) are killed so they
89
            // are neither orphaned nor left as zombies.
90
            foreach ($pids as $pid) {
16✔
91
                if (isset($reaped[$pid])) {
16✔
92
                    continue;
15✔
93
                }
94
                if (function_exists('posix_kill')) {
1✔
95
                    posix_kill($pid, 9); // SIGKILL
1✔
96
                    pcntl_waitpid($pid, $status);
1✔
97
                } else {
98
                    pcntl_waitpid($pid, $status, WNOHANG); // @codeCoverageIgnore
99
                }
100
            }
101

102
            // Clean up temp files
103
            foreach ($tempFiles as $file) {
16✔
104
                if (is_file($file)) {
16✔
105
                    @unlink($file);
16✔
106
                }
107
            }
108
        }
109

110
        return array_values($results);
12✔
111
    }
112

113
    /**
114
     * Terminate the forked child immediately, without running destructors,
115
     * shutdown functions, or flushing output buffers inherited from the parent
116
     * process — the child's shutdown sequence must not tear down shared
117
     * resources such as database connections, or emit the parent's buffered
118
     * output a second time.
119
     *
120
     * @codeCoverageIgnore Runs in the forked child only
121
     */
122
    private static function exitChild(): never
123
    {
124
        if (function_exists('posix_kill') && function_exists('posix_getpid')) {
125
            posix_kill(posix_getpid(), 9); // SIGKILL — bypasses PHP shutdown
126
        }
127

128
        exit(0);
129
    }
130

131
    /**
132
     * Write the result envelope from the forked child. A missing or partial
133
     * write is detected by the parent when it fails to unserialize a complete
134
     * envelope from the file, so there is nothing useful to do on failure here.
135
     *
136
     * @codeCoverageIgnore Runs in the forked child only
137
     *
138
     * @param array{ok: bool, result?: mixed, error?: ParallelTaskError} $envelope
139
     */
140
    private static function writeChildResult(string $tempFile, array $envelope): void
141
    {
142
        @file_put_contents($tempFile, serialize($envelope));
143
    }
144

145
    private function collectResult(int $taskIndex, string $tempFile, int $status): mixed
15✔
146
    {
147
        $content = is_file($tempFile) ? file_get_contents($tempFile) : false;
15✔
148
        $envelope = ($content === false || $content === '') ? false : @unserialize($content);
15✔
149

150
        if (!is_array($envelope) || !array_key_exists('ok', $envelope)) {
15✔
151
            throw new Exception("Parallel task {$taskIndex} did not return a result ({$this->describeChildStatus($status)})");
1✔
152
        }
153

154
        if ($envelope['ok'] !== true) {
15✔
155
            $error = $envelope['error'] ?? null;
2✔
156
            $detail = $error instanceof ParallelTaskError ? $error->getSummary() : 'unknown error';
2✔
157

158
            throw new Exception("Parallel task {$taskIndex} failed: {$detail}");
2✔
159
        }
160

161
        return $envelope['result'] ?? null;
15✔
162
    }
163

164
    private function describeChildStatus(int $status): string
1✔
165
    {
166
        if (pcntl_wifsignaled($status)) {
1✔
167
            return 'child killed by signal ' . pcntl_wtermsig($status);
1✔
168
        }
NEW
169
        if (pcntl_wifexited($status)) {
×
170
            return 'child exited with code ' . pcntl_wexitstatus($status); // @codeCoverageIgnore
171
        }
172

173
        return 'child status unknown'; // @codeCoverageIgnore
174
    }
175

176
    private function waitForChild(int $pid): int
16✔
177
    {
178
        $startTime = time();
16✔
179

180
        while (true) {
16✔
181
            $status = 0;
16✔
182
            $result = pcntl_waitpid($pid, $status, WNOHANG);
16✔
183

184
            if ($result === $pid) {
16✔
185
                return is_int($status) ? $status : 0;
15✔
186
            }
187

188
            if ($result === -1) {
16✔
189
                return 0; // @codeCoverageIgnore
190
            }
191

192
            if ($this->timeout > 0 && (time() - $startTime) >= $this->timeout) {
16✔
193
                $this->terminateChild($pid);
1✔
194

195
                throw new Exception("Parallel task timed out after {$this->timeout} seconds");
1✔
196
            }
197

198
            usleep(10000); // 10ms poll interval
16✔
199
        }
200
    }
201

202
    /**
203
     * Terminate a child with SIGTERM, escalating to SIGKILL if it has not
204
     * exited within the grace period, then reap it so it cannot linger as
205
     * an orphan or zombie.
206
     */
207
    private function terminateChild(int $pid): void
1✔
208
    {
209
        if (function_exists('posix_kill')) {
1✔
210
            posix_kill($pid, 15); // SIGTERM
1✔
211
            $deadline = microtime(true) + self::SIGTERM_GRACE_SECONDS;
1✔
212
            while (microtime(true) < $deadline) {
1✔
213
                if (pcntl_waitpid($pid, $status, WNOHANG) === $pid) {
1✔
214
                    return;
1✔
215
                }
216
                usleep(10000);
1✔
217
            }
NEW
218
            posix_kill($pid, 9); // SIGKILL
×
NEW
219
            pcntl_waitpid($pid, $status);
×
220

NEW
221
            return;
×
222
        }
223

224
        pcntl_waitpid($pid, $status, WNOHANG); // @codeCoverageIgnore
225
    }
226

227
    public static function isAvailable(): bool
19✔
228
    {
229
        // Forking is only safe from the CLI: under FPM or an Apache handler a
230
        // forked child would share the SAPI's sockets and process-pool state
231
        return PHP_SAPI === 'cli'
19✔
232
            && function_exists('pcntl_fork')
19✔
233
            && function_exists('pcntl_waitpid')
19✔
234
            && PHP_OS_FAMILY !== 'Windows';
19✔
235
    }
236
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc