-
Notifications
You must be signed in to change notification settings - Fork 79
Expand file tree
/
Copy pathphp-process.ts
More file actions
258 lines (232 loc) · 8.13 KB
/
Copy pathphp-process.ts
File metadata and controls
258 lines (232 loc) · 8.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
import { ChildProcess, spawn, spawnSync } from 'node:child_process';
import os from 'node:os';
import { getPhpBinaryPath } from 'cli/lib/dependency-management/paths';
import { getDefaultPhpArgs } from 'cli/lib/native-php/config';
import type { NativePhpSupportedVersion } from '@studio/common/lib/php-binary-metadata';
type ErrorLogger = ( ...args: Parameters< typeof console.error > ) => void;
// Makes a PHP child a process-group leader on POSIX so its subtree can be signalled via the
// negative PID. On Windows we reap with `taskkill /T` instead, so a new group isn't needed.
export const DETACH_FOR_GROUP_KILL = process.platform !== 'win32';
// Every PHP process spawned through `spawnPhpProcess` that hasn't exited, so shutdown can reap
// in-flight children (mid-startup workers, install/blueprint subprocesses) callers don't track.
const livePhpProcesses = new Set< ChildProcess >();
export type BasePhpOptions = {
autoPrependFile?: string;
detached?: boolean;
disallowRiskyFunctions?: boolean;
env?: NodeJS.ProcessEnv;
enableXdebug?: boolean;
onlyPathsThatPhpCanAccess?: string[];
phpVersion: NativePhpSupportedVersion;
siteFolder?: string;
signal?: AbortSignal;
};
type SpawnPhpProcessOptions = BasePhpOptions & {
mode?: 'pipe' | 'no-pipe';
};
type RunPhpCommandOptions = BasePhpOptions & {
mode?: 'pipe' | 'no-pipe' | 'capture';
};
export function spawnPhpProcess(
args: string[],
{
phpVersion,
siteFolder,
signal,
env,
mode = 'pipe',
detached = false,
enableXdebug = false,
onlyPathsThatPhpCanAccess = [],
disallowRiskyFunctions = false,
autoPrependFile,
}: SpawnPhpProcessOptions
): ChildProcess {
const defaultArgs = getDefaultPhpArgs(
phpVersion,
onlyPathsThatPhpCanAccess,
disallowRiskyFunctions,
enableXdebug
);
// Run a PHP file before the main script on every request — used to inject
// reprint's generated runtime.php (constants, SQLite loader, upload proxy)
// into imported sites without modifying their wp-config.php.
const prependArgs = autoPrependFile ? [ '-d', `auto_prepend_file=${ autoPrependFile }` ] : [];
const phpArgs = [ ...defaultArgs, ...prependArgs, ...args ];
const phpScriptProcess = spawn( getPhpBinaryPath( phpVersion ), phpArgs, {
cwd: siteFolder,
env: env ? { ...process.env, ...env } : process.env,
stdio: [ 'ignore', 'pipe', 'pipe' ],
signal,
detached,
} );
// Track from the instant of spawn so shutdown can reap this child even before callers
// store it in their own state. Deregister on exit to keep the set live.
livePhpProcesses.add( phpScriptProcess );
phpScriptProcess.once( 'exit', () => livePhpProcesses.delete( phpScriptProcess ) );
if ( mode === 'pipe' ) {
phpScriptProcess.stdout?.pipe( process.stdout, { end: false } );
phpScriptProcess.stderr?.pipe( process.stderr, { end: false } );
}
return phpScriptProcess;
}
// Force-kill every tracked PHP process so none outlives the wrapper. Tree-kills because on Windows
// TerminateProcess doesn't cascade — a worker's subprocess would survive and keep DLLs locked.
export function killAllLivePhpProcesses(): void {
for ( const child of livePhpProcesses ) {
try {
// Detach the unexpected-exit listener so the imminent kill is not logged as a crash.
child.removeAllListeners( 'exit' );
if ( child.exitCode === null && child.signalCode === null ) {
killPhpProcessTree( child, 'SIGKILL' );
}
} catch {
// Best effort - nothing useful to do if this fails.
}
}
livePhpProcesses.clear();
}
// Terminate a PHP child and its descendants: `taskkill /T` on Windows (TerminateProcess doesn't
// cascade), or the process group on POSIX (requires `DETACH_FOR_GROUP_KILL`), falling back to the
// lone child.
export function killPhpProcessTree(
child: ChildProcess,
signal: NodeJS.Signals = 'SIGKILL'
): void {
const pid = child.pid;
if ( ! pid ) {
return;
}
if ( process.platform === 'win32' ) {
// Bounded so a hung taskkill can't stall the caller's event loop indefinitely (which would
// hang shutdown). `signal`/`error` on the result means it was cut off before finishing —
// log it, since that's the smoking gun for a process tree that won't die.
const result = spawnSync( 'taskkill', [ '/F', '/T', '/PID', String( pid ) ], {
windowsHide: true,
stdio: 'ignore',
timeout: 2_000,
} );
if ( result.error || result.signal ) {
console.error(
`[PHP] taskkill for pid ${ pid } did not complete (signal: ${ result.signal }, error: ${ result.error?.message })`
);
}
return;
}
try {
process.kill( -pid, signal );
} catch {
try {
child.kill( signal );
} catch {
// Already gone.
}
}
}
// On SIGINT/SIGTERM, tears down the PHP child's tree and exits 128+signal so php.exe and its
// grandchildren don't outlive the command. Returns a disposer to remove the handlers once the
// command settles. (SIGKILL can't be caught — Studio's quit handler tree-kills for that.)
export function reapPhpTreeOnInterrupt( child: ChildProcess ): () => void {
const handleInterrupt = ( signal: NodeJS.Signals ) => {
// Forward the signal to the group so php shuts down like it would on a terminal Ctrl+C,
// rather than being hard-killed. (Moot on Windows — `taskkill /F` is the only option.)
killPhpProcessTree( child, signal );
process.exit( 128 + ( os.constants.signals[ signal ] ?? 0 ) );
};
const onSigint = () => handleInterrupt( 'SIGINT' );
const onSigterm = () => handleInterrupt( 'SIGTERM' );
process.on( 'SIGINT', onSigint );
process.on( 'SIGTERM', onSigterm );
return () => {
process.off( 'SIGINT', onSigint );
process.off( 'SIGTERM', onSigterm );
};
}
export async function runPhpCommand(
args: string[],
options: RunPhpCommandOptions
): Promise< { stdout: string; stderr: string } > {
return await new Promise< { stdout: string; stderr: string } >( ( resolve, reject ) => {
const phpScriptProcess = spawnPhpProcess( args, {
...options,
mode: options.mode === 'capture' ? 'no-pipe' : options.mode,
} );
const reportActivity = () => process.send?.( { topic: 'activity' } );
let stdout = '';
phpScriptProcess.stdout?.on( 'data', ( chunk ) => {
reportActivity();
if ( options.mode === 'capture' ) {
stdout += chunk.toString();
}
} );
let stderr = '';
phpScriptProcess.stderr?.on( 'data', ( chunk ) => {
reportActivity();
if ( options.mode === 'capture' ) {
stderr += chunk.toString();
}
} );
phpScriptProcess.once( 'error', ( error: Error ) => {
reject( error );
} );
phpScriptProcess.once( 'close', ( code ) => {
if ( code === 0 ) {
resolve( { stdout, stderr } );
return;
}
reject( new Error( `PHP command failed (code: ${ code })` ) );
} );
} );
}
export async function waitForChildSpawn(
child: ChildProcess,
signal?: AbortSignal
): Promise< void > {
await new Promise< void >( ( resolve, reject ) => {
child.once( 'spawn', () => {
resolve();
} );
child.once( 'error', ( error: Error ) => {
reject( error );
} );
signal?.addEventListener( 'abort', () => {
reject( new DOMException( 'Aborted', 'AbortError' ) );
} );
} );
}
export async function stopPhpChild(
child: ChildProcess,
timeoutMs: number,
errorToConsole: ErrorLogger
): Promise< void > {
child.removeAllListeners( 'exit' );
if ( child.exitCode !== null || child.signalCode !== null ) {
return;
}
await new Promise< void >( ( resolve ) => {
let settled = false;
const finish = () => {
if ( settled ) {
return;
}
settled = true;
child.off( 'exit', finish );
resolve();
};
// Resolve on 'exit', not 'close': a descendant that inherited the stdio pipes can hold them
// open after the child dies, so 'close' may never fire and would hang the stop indefinitely.
child.once( 'exit', finish );
// Tree-kill so the child's subprocesses die too (Windows TerminateProcess doesn't cascade);
// otherwise they keep DLLs locked and hold the stdio pipes open.
killPhpProcessTree( child, 'SIGTERM' );
setTimeout( () => {
if ( settled ) {
return;
}
errorToConsole( 'PHP child did not exit in time; force-killing its process tree' );
killPhpProcessTree( child, 'SIGKILL' );
// Backstop: resolve even if 'exit' is somehow delayed, so the stop can never hang.
setTimeout( finish, 1000 );
}, timeoutMs );
} );
}