-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathunhandled_exception.rs
More file actions
570 lines (536 loc) · 25 KB
/
Copy pathunhandled_exception.rs
File metadata and controls
570 lines (536 loc) · 25 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
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
//! Normalize the terminal PHP error of a failed request into a `CapturedException`.
//!
//! Every failing request now carries the uncaught exception's class
//! *structurally* in `exception_class` — the worker fiber-catch site sets it
//! from `EG(exception)->ce`, and the traditional path sets it from a
//! `zend_throw_exception_hook` snapshot taken at throw time. So the class (the
//! error-inbox bucketing key) is never derived from the formatted, partly
//! user-controlled fatal text.
//!
//! Two message shapes still feed in:
//! * Worker — a clean `message` (the exception's own message) plus a structural
//! `stacktrace` (`getTraceAsString`). Used verbatim.
//! * Traditional/Framework/SPA — `oxphp_error_cb` records the engine's uncaught
//! fatal as `E_ERROR` with message `Uncaught <Class>: <msg> in <file>:<line>\n
//! Stack trace:\n<trace>\n thrown`. The `message`/`stacktrace` are parsed out
//! of that text, but the structural `exception_class` overrides whatever class
//! the text would yield — so a message that forges a `\n\nNext <FakeClass>: …`
//! segment cannot poison `exception.type`.
use crate::types::{CapturedException, PhpScriptError};
/// Scan a request's error stream for the terminal failure and normalize it.
/// `None` if there is none.
///
/// Reports the *earliest* `error`-level entry — the fatal that actually
/// terminated the request. The first fatal bails the request out; any further
/// `error`-level entries come from shutdown functions or destructors running
/// afterwards and must not shadow the killer. This holds whether the killer is an
/// uncaught exception or a classless fatal (OOM, `trigger_error(E_USER_ERROR)`,
/// timeout): selecting "the earliest entry that has a class" would skip a
/// classless killer in favour of a later shutdown-thrown exception. Matches the
/// worker path, which records the single escaping exception once.
pub fn extract_unhandled_exception(errors: &[PhpScriptError]) -> Option<CapturedException> {
let err = errors.iter().find(|e| e.level == "error")?;
// `oxphp_error_cb` substitutes the literal "unknown" for a NULL zend
// filename; treat it as absent so `exception.file` is omitted (like
// `line == 0`) rather than exported with a placeholder value.
let file = (!err.file.is_empty() && err.file != "unknown").then(|| err.file.clone());
let line = (err.line != 0).then_some(err.line);
// Structural class present (worker fiber-catch, or the traditional throw-hook
// snapshot). Use it verbatim — never the parsed text — so the bucketing key
// is robust.
if let Some(class) = &err.exception_class {
// Traditional path: the engine's "Uncaught …" text still lives in
// `message` and there is no structural stacktrace. Parse the
// message/stacktrace out of the text (best-effort), but keep the
// structural class.
if err.stacktrace.is_none() && err.message.starts_with("Uncaught ") {
if let Some((_parsed_class, message, stacktrace)) =
parse_uncaught(&err.message, err.file.as_str(), err.line)
{
return Some(CapturedException {
exception_type: class.clone(),
message,
stacktrace,
file,
line,
});
}
}
// Worker path: clean `message` + structural `stacktrace`. Use verbatim.
return Some(CapturedException {
exception_type: class.clone(),
message: (!err.message.is_empty()).then(|| err.message.clone()),
stacktrace: err.stacktrace.clone(),
file,
line,
});
}
// No structural class (the throw-hook missed, or a classless fatal). Only a
// genuine engine `E_ERROR` "Uncaught …" fatal is a real Throwable, so parse
// its class from the text (best-effort — a chained message can shift the
// parse; see `parse_uncaught`). A classless fatal whose *message* merely
// starts with "Uncaught " — e.g. `trigger_error('Uncaught PDOException: …',
// E_USER_ERROR)` — must NOT borrow that forged class: `trigger_error` cannot
// raise `E_ERROR`, so gating on the type keeps it on the classless branch
// below where `exception.type` becomes the honest error constant.
if err.error_type == "E_ERROR" {
if let Some((class, message, stacktrace)) =
parse_uncaught(&err.message, err.file.as_str(), err.line)
{
return Some(CapturedException {
exception_type: class,
message,
stacktrace,
file,
line,
});
}
}
// Plain fatal (E_USER_ERROR, OOM, timeout, …): no Throwable, no trace.
Some(CapturedException {
exception_type: err.error_type.to_string(),
message: (!err.message.is_empty()).then(|| err.message.clone()),
stacktrace: None,
file,
line,
})
}
/// Parse an `Uncaught <Class>[: <message>] in <file>:<line>\nStack trace:\n<trace>\n thrown`
/// message. `file`/`line` are the structurally-known origin, used to strip the
/// ` in <file>:<line>` header tail robustly. Returns `None` if not an uncaught
/// message (caller falls back to plain-fatal handling).
///
/// The returned class is used only when no structural `exception_class` is
/// available; the caller prefers the structural class. The `message`/`stacktrace`
/// are always taken from here on the traditional path — they describe the
/// outermost (thrown) exception.
fn parse_uncaught(
msg: &str,
file: &str,
line: u32,
) -> Option<(String, Option<String>, Option<String>)> {
let body = msg.strip_prefix("Uncaught ")?;
let body = body.strip_suffix("\n thrown").unwrap_or(body);
// Stack trace = everything after the FIRST header line. For a chained
// exception this keeps the whole "…\n\nNext <thrown> …" chain — useful for
// debugging.
let stacktrace = body
.split_once("\nStack trace:\n")
.map(|(_, t)| t.trim_end().to_string());
// Pick the header describing the exception that actually ESCAPED — the
// OUTERMOST. PHP's `Exception::__toString` (Zend/zend_exceptions.c) renders a
// chain root-cause-first and appends each outer link after a "\n\nNext "
// delimiter, so the thrown exception is the LAST segment and the `Uncaught`
// fatal's file:line is its origin. Crucially, every non-last link is
// terminated by its own "\nStack trace:\n" BEFORE that delimiter.
//
// A single exception whose own *message* merely embeds the literal
// "\n\nNext " has no such trace before the first delimiter — its one trace is
// appended once, after the whole message. That structural difference tells a
// genuine chain boundary from a spurious in-message one without trusting the
// partly user-controlled class text (a class-name check mislabels the common
// same-class re-wrap `catch (X) { throw new X(..., previous: $e) }` as a
// single exception and reports the root cause's message):
// * segment before the first "\n\nNext " contains "\nStack trace:\n"
// → real chain → take the last segment (the thrown exception).
// * otherwise (embedded delimiter, or no delimiter at all)
// → one exception → take the whole first header, so the message survives.
//
// (A message that also embeds "\nStack trace:\n" can still force the chain
// branch and truncate itself — a documented best-effort limit; the bucketing
// `exception.type` is structural and unaffected either way.)
let is_real_chain = body
.split_once("\n\nNext ")
.is_some_and(|(first, _)| first.contains("\nStack trace:\n"));
let header = if is_real_chain {
let outer_segment = body.rsplit("\n\nNext ").next().unwrap_or(body);
strip_origin_tail(header_line(outer_segment), file, line)
} else {
strip_origin_tail(header_line(body), file, line)
};
// Class is up to the first ": " (class names never contain ": ").
let (class, message) = match header.split_once(": ") {
Some((c, m)) => (c.to_string(), Some(m.to_string())),
None => (header.to_string(), None),
};
Some((class, message, stacktrace))
}
/// The header line of a chain segment: everything before its `"\nStack trace:\n"`.
fn header_line(segment: &str) -> &str {
segment
.split_once("\nStack trace:\n")
.map(|(h, _)| h)
.unwrap_or(segment)
}
/// Strip the ` in <file>:<line>` origin tail using the structurally-known origin,
/// isolating `"<Class>[: <message>]"`. Robust even if the message contains ` in `.
fn strip_origin_tail<'a>(header: &'a str, file: &str, line: u32) -> &'a str {
if !file.is_empty() && line != 0 {
let tail = format!(" in {file}:{line}");
header.strip_suffix(&tail).unwrap_or(header)
} else {
header
}
}
#[cfg(test)]
mod tests {
use super::*;
fn err(
level: &'static str,
error_type: &'static str,
message: &str,
file: &str,
line: u32,
) -> PhpScriptError {
PhpScriptError {
level,
error_type,
message: message.into(),
file: file.into(),
line,
stacktrace: None,
exception_class: None,
}
}
#[test]
fn simple_uncaught_exception() {
let msg = "Uncaught RuntimeException: payment failed in /app/pay.php:42\nStack trace:\n#0 /app/pay.php(88): charge()\n#1 {main}\n thrown";
let e = err("error", "E_ERROR", msg, "/app/pay.php", 42);
let c = extract_unhandled_exception(&[e]).unwrap();
assert_eq!(c.exception_type, "RuntimeException");
assert_eq!(c.message.as_deref(), Some("payment failed"));
assert_eq!(c.file.as_deref(), Some("/app/pay.php"));
assert_eq!(c.line, Some(42));
assert!(c
.stacktrace
.as_deref()
.unwrap()
.starts_with("#0 /app/pay.php(88): charge()"));
assert!(c.stacktrace.as_deref().unwrap().ends_with("{main}"));
}
#[test]
fn message_with_colons() {
let msg = "Uncaught PDOException: SQLSTATE[HY000]: general error in /db.php:10\nStack trace:\n#0 {main}\n thrown";
let c =
extract_unhandled_exception(&[err("error", "E_ERROR", msg, "/db.php", 10)]).unwrap();
assert_eq!(c.exception_type, "PDOException");
assert_eq!(c.message.as_deref(), Some("SQLSTATE[HY000]: general error"));
}
#[test]
fn chained_takes_thrown_not_root_cause() {
// Real PHP shape for `throw new ApiException('api failed', previous:
// new PDOException('db down'))`: __toString renders the root cause
// (PDOException) first and appends the thrown ApiException after the
// final "\n\nNext "; the Uncaught fatal's file:line is the thrown one's.
let msg = "Uncaught PDOException: db down in /db.php:10\nStack trace:\n#0 /db.php(5): connect()\n#1 {main}\n\nNext ApiException: api failed in /api.php:20\nStack trace:\n#0 /api.php(15): handle()\n#1 {main}\n thrown";
let c =
extract_unhandled_exception(&[err("error", "E_ERROR", msg, "/api.php", 20)]).unwrap();
// Bucket on the exception that actually escaped, not its root cause.
assert_eq!(c.exception_type, "ApiException");
assert_eq!(c.message.as_deref(), Some("api failed"));
// No " in <file>:<line>" glued into the message.
assert!(!c.message.as_deref().unwrap().contains(" in "));
assert_eq!(c.file.as_deref(), Some("/api.php"));
assert_eq!(c.line, Some(20));
// The full chain (root cause first) survives in the stacktrace.
let trace = c.stacktrace.as_deref().unwrap();
assert!(trace.starts_with("#0 /db.php(5): connect()"));
assert!(trace.contains("Next ApiException: api failed"));
}
#[test]
fn empty_message_form() {
let msg = "Uncaught LogicException in /x.php:3\nStack trace:\n#0 {main}\n thrown";
let c = extract_unhandled_exception(&[err("error", "E_ERROR", msg, "/x.php", 3)]).unwrap();
assert_eq!(c.exception_type, "LogicException");
assert_eq!(c.message, None);
assert_eq!(c.line, Some(3));
}
#[test]
fn plain_fatal_no_class_no_trace() {
// A genuine classless fatal (no Throwable): E_USER_ERROR from
// trigger_error, OOM, or a timeout. The message has no "Uncaught "
// prefix and there is no stack trace, so the synthetic type is the
// error constant. (An undefined-function call is NOT this shape on
// PHP 8 — it throws a Throwable `Error` with a full trace.)
let c = extract_unhandled_exception(&[err(
"error",
"E_USER_ERROR",
"fatal path: kaboom",
"/x.php",
7,
)])
.unwrap();
assert_eq!(c.exception_type, "E_USER_ERROR");
assert_eq!(c.message.as_deref(), Some("fatal path: kaboom"));
assert_eq!(c.stacktrace, None);
assert_eq!(c.file.as_deref(), Some("/x.php"));
assert_eq!(c.line, Some(7));
}
#[test]
fn shutdown_error_does_not_shadow_uncaught() {
// The uncaught exception is recorded first; a shutdown function then
// raises its own fatal. The span must report the exception that killed
// the request, not the later shutdown-time error.
let errs = vec![
err(
"error",
"E_ERROR",
"Uncaught RuntimeException: real killer in /app.php:9\nStack trace:\n#0 {main}\n thrown",
"/app.php",
9,
),
err("error", "E_USER_ERROR", "shutdown logger blew up", "/shutdown.php", 3),
];
let c = extract_unhandled_exception(&errs).unwrap();
assert_eq!(c.exception_type, "RuntimeException");
assert_eq!(c.message.as_deref(), Some("real killer"));
}
#[test]
fn earliest_uncaught_wins_over_shutdown_uncaught() {
// Handler throws (recorded first), then a shutdown function throws its
// own *uncaught* exception (recorded later, structural class too). The
// span must report the request-killer, not the shutdown-time throw —
// matching the worker path, which records the escaping exception once.
let mut killer = err(
"error",
"E_ERROR",
"Uncaught RuntimeException: real killer in /app.php:9\nStack trace:\n#0 {main}\n thrown",
"/app.php",
9,
);
killer.exception_class = Some("RuntimeException".into());
let mut shutdown = err(
"error",
"E_ERROR",
"Uncaught JsonException: shutdown blew up in /sd.php:3\nStack trace:\n#0 {main}\n thrown",
"/sd.php",
3,
);
shutdown.exception_class = Some("JsonException".into());
let c = extract_unhandled_exception(&[killer, shutdown]).unwrap();
assert_eq!(c.exception_type, "RuntimeException");
assert_eq!(c.message.as_deref(), Some("real killer"));
}
#[test]
fn earliest_classless_killer_wins_over_shutdown_throw() {
// A classless fatal (OOM / trigger_error(E_USER_ERROR)) terminates the
// request first; a shutdown function then throws an uncaught exception
// (recorded later, and it *does* carry a class). The span must still
// report the real killer, not the later shutdown throw — selecting the
// earliest entry *with a class* would invert this.
let killer = err(
"error",
"E_USER_ERROR",
"Allowed memory size of 134217728 bytes exhausted",
"/app.php",
42,
);
let mut shutdown = err(
"error",
"E_ERROR",
"Uncaught LogicException: shutdown blew up in /sd.php:3\nStack trace:\n#0 {main}\n thrown",
"/sd.php",
3,
);
shutdown.exception_class = Some("LogicException".into());
let c = extract_unhandled_exception(&[killer, shutdown]).unwrap();
assert_eq!(c.exception_type, "E_USER_ERROR");
assert_eq!(
c.message.as_deref(),
Some("Allowed memory size of 134217728 bytes exhausted")
);
assert_eq!(c.stacktrace, None);
}
#[test]
fn classless_fatal_with_uncaught_message_is_not_forged_throwable() {
// A classless fatal (no structural class) whose message merely *starts*
// with "Uncaught " must NOT be parsed into that class — `trigger_error`
// cannot raise E_ERROR, so an E_USER_ERROR "Uncaught PDOException: …"
// is an operator/attacker forgery, not a real Throwable. exception.type
// stays the honest error constant.
let c = extract_unhandled_exception(&[err(
"error",
"E_USER_ERROR",
"Uncaught PDOException: forged in /app.php:10",
"/app.php",
10,
)])
.unwrap();
assert_eq!(c.exception_type, "E_USER_ERROR");
assert_ne!(c.exception_type, "PDOException");
// The whole forged text rides through as the message, unparsed.
assert_eq!(
c.message.as_deref(),
Some("Uncaught PDOException: forged in /app.php:10")
);
assert_eq!(c.stacktrace, None);
}
#[test]
fn classless_uncaught_still_parsed_for_genuine_e_error() {
// A real engine E_ERROR "Uncaught …" fatal with no structural class
// (throw-hook missed) is still parsed — the type-gate only excludes the
// non-E_ERROR forgery above, never a genuine uncaught throw.
let c = extract_unhandled_exception(&[err(
"error",
"E_ERROR",
"Uncaught RuntimeException: real in /app.php:9\nStack trace:\n#0 {main}\n thrown",
"/app.php",
9,
)])
.unwrap();
assert_eq!(c.exception_type, "RuntimeException");
assert_eq!(c.message.as_deref(), Some("real"));
}
#[test]
fn traditional_structural_class_overrides_forged_message() {
// Traditional path: the throw-hook captured the real class structurally,
// while the engine's fatal text carries a user message that forges a
// "\n\nNext FakeClass: …" segment. exception.type must be the structural
// class, never the forged one; message/stacktrace still come from the text.
let mut e = err(
"error",
"E_ERROR",
"Uncaught RealException: oops\n\nNext FakeClass: pwned in /app.php:5\nStack trace:\n#0 {main}\n thrown",
"/app.php",
5,
);
e.exception_class = Some("RealException".into());
let c = extract_unhandled_exception(&[e]).unwrap();
assert_eq!(c.exception_type, "RealException");
assert_ne!(c.exception_type, "FakeClass");
assert_eq!(c.file.as_deref(), Some("/app.php"));
assert_eq!(c.line, Some(5));
// The whole real message rides through intact — the forged "\n\nNext
// FakeClass: …" is part of this exception's own message, not a chain
// boundary that truncates it (previously it became just "pwned").
assert_eq!(c.message.as_deref(), Some("oops\n\nNext FakeClass: pwned"));
// A traditional structural entry still gets its trace from the text.
assert!(c.stacktrace.as_deref().unwrap().contains("{main}"));
}
#[test]
fn structural_class_recovers_message_with_next_substring() {
// A SINGLE exception whose own message contains the literal "\n\nNext " is
// textually indistinguishable from a two-link chain. The structural class
// anchors recovery so the full message survives; previously
// rsplit("\n\nNext ") kept only the "steps: contact support" tail and
// mislabeled it (message became "contact support").
let mut e = err(
"error",
"E_ERROR",
"Uncaught RuntimeException: Payment failed.\n\nNext steps: contact support in /pay.php:5\nStack trace:\n#0 {main}\n thrown",
"/pay.php",
5,
);
e.exception_class = Some("RuntimeException".into());
let c = extract_unhandled_exception(&[e]).unwrap();
assert_eq!(c.exception_type, "RuntimeException");
assert_eq!(
c.message.as_deref(),
Some("Payment failed.\n\nNext steps: contact support")
);
assert_eq!(c.file.as_deref(), Some("/pay.php"));
assert_eq!(c.line, Some(5));
}
#[test]
fn structural_class_real_chain_takes_thrown_message() {
// Structural class AND a genuine chain (root cause != thrown): the first
// header names the root cause (PDOException), which does NOT match the
// escaped class (ApiException), so the parse falls back to the last
// "\n\nNext " segment and reports the thrown exception's message.
let mut e = err(
"error",
"E_ERROR",
"Uncaught PDOException: db down in /db.php:10\nStack trace:\n#0 {main}\n\nNext ApiException: api failed in /api.php:20\nStack trace:\n#0 {main}\n thrown",
"/api.php",
20,
);
e.exception_class = Some("ApiException".into());
let c = extract_unhandled_exception(&[e]).unwrap();
assert_eq!(c.exception_type, "ApiException");
assert_eq!(c.message.as_deref(), Some("api failed"));
assert!(!c.message.as_deref().unwrap().contains(" in "));
}
#[test]
fn structural_class_same_class_chain_takes_thrown_message() {
// Regression guard for a genuine chain whose root cause and thrown
// exception SHARE a class — `catch (RuntimeException $e) { throw new
// RuntimeException(..., previous: $e); }`, an extremely common re-wrap.
// __toString renders the root cause ("inner cause") first and the thrown
// wrapper ("outer wrap") last; the fatal's file:line is the wrapper's.
// The message must be the thrown "outer wrap", never the root cause, and
// never with an " in <file>:<line>" tail glued on. A class-name
// disambiguation matched the root header (same class) and reported
// "inner cause in /x.php:3"; the structural "is there a \nStack trace:\n
// before the first \n\nNext " signal keeps this on the chain branch.
let mut e = err(
"error",
"E_ERROR",
"Uncaught RuntimeException: inner cause in /x.php:3\nStack trace:\n#0 /x.php(3): a()\n#1 {main}\n\nNext RuntimeException: outer wrap in /x.php:5\nStack trace:\n#0 /x.php(5): b()\n#1 {main}\n thrown",
"/x.php",
5,
);
e.exception_class = Some("RuntimeException".into());
let c = extract_unhandled_exception(&[e]).unwrap();
assert_eq!(c.exception_type, "RuntimeException");
assert_eq!(c.message.as_deref(), Some("outer wrap"));
assert!(!c.message.as_deref().unwrap().contains(" in "));
assert_eq!(c.line, Some(5));
}
#[test]
fn structural_class_empty_message_form() {
// Empty-message form on the structural path: the header is exactly the
// class, so message is None (not an empty string).
let mut e = err(
"error",
"E_ERROR",
"Uncaught LogicException in /x.php:3\nStack trace:\n#0 {main}\n thrown",
"/x.php",
3,
);
e.exception_class = Some("LogicException".into());
let c = extract_unhandled_exception(&[e]).unwrap();
assert_eq!(c.exception_type, "LogicException");
assert_eq!(c.message, None);
}
#[test]
fn unknown_file_is_omitted() {
// NULL zend filename arrives as the literal "unknown"; omit the
// attribute rather than exporting the placeholder.
let c =
extract_unhandled_exception(&[err("error", "E_ERROR", "boom", "unknown", 0)]).unwrap();
assert_eq!(c.file, None);
assert_eq!(c.line, None);
}
#[test]
fn worker_prestructured_used_directly() {
let mut e = err("error", "E_ERROR", "boom", "/w.php", 11);
e.exception_class = Some("TypeError".into());
e.stacktrace = Some("#0 /w.php(11): h()\n#1 {main}".into());
let c = extract_unhandled_exception(&[e]).unwrap();
assert_eq!(c.exception_type, "TypeError");
assert_eq!(c.message.as_deref(), Some("boom"));
assert_eq!(
c.stacktrace.as_deref(),
Some("#0 /w.php(11): h()\n#1 {main}")
);
}
#[test]
fn picks_last_error_ignores_warnings() {
let errs = vec![
err("warn", "E_WARNING", "deprecated thing", "/x.php", 1),
err(
"error",
"E_ERROR",
"Uncaught RuntimeException: boom in /x.php:9\nStack trace:\n#0 {main}\n thrown",
"/x.php",
9,
),
];
let c = extract_unhandled_exception(&errs).unwrap();
assert_eq!(c.exception_type, "RuntimeException");
}
#[test]
fn none_when_no_error_level() {
assert!(extract_unhandled_exception(&[err("warn", "E_WARNING", "x", "/a", 1)]).is_none());
}
}