Skip to content
This repository was archived by the owner on Mar 24, 2022. It is now read-only.

Commit 0b51fe7

Browse files
authored
🏎️ Signal handler/timeout race fix! (#507)
* test that a SIGALRM sent to a guest thread handling a signal is well-behaved * additional test to confirm worst-case spurious sigalrm is well-behaved
1 parent e890e0a commit 0b51fe7

File tree

7 files changed

+334
-38
lines changed

7 files changed

+334
-38
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
Creating or resetting an instance no longer implicitly runs the start function. Embedders must ensure that `run_start()` is called before calling any other exported functions. `Instance::run()` will now return `Err(Error::InstanceNeedsStart)` if the start function is present but hasn't been run since the instance was created or reset.
88

9+
- Corrected a race condition where a `KillSwitch` fired while lucet-runtime is handling a guest fault could result in a SIGALRM or panic in the Lucet embedder.
10+
911
[start-function]: https://webassembly.github.io/spec/core/syntax/modules.html#syntax-start
1012

1113
### 0.6.1 (2020-02-18)

docs/src/lucet-runtime/killswitch.md

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -367,11 +367,66 @@ handlers used with Lucet is that they may not lock on `KillState`'s
367367

368368
As a consequence, a `KillSwitch` may fire during the handling of a guest fault.
369369
`sigaction` must mask `SIGALRM` so that a signal fired before the handler exits
370-
is discarded. If the signal behavior is to continue without effect, leave
371-
termination in place and continue to the guest. Otherwise the signal handler
372-
has determined it must return to the host, and it disables termination on the
373-
instance to avoid sending an erroneous SIGALRM immediately after swapping to
374-
the host context.
370+
does not preempt the handler. If the signal behavior is to continue without
371+
effect, leave termination in place and continue to the guest. A pending SIGALRM
372+
will be raised at this point and the instance will exit. Otherwise, the signal
373+
handler has determined it must return to the host, and must be sensitive to a
374+
possible in-flight `KillSwitch`...
375+
376+
#### Instance-stopping guest fault with concurrent `KillSwitch`
377+
378+
In this case we must consider three constraints:
379+
* A `KillSwitch` may fire and deliver a `SIGALRM` at any point
380+
* A `SIGALRM` may already have been fired, pending on the handler returning
381+
* The signal handler must return to host code
382+
383+
First, we handle the risk of a `KillSwitch` firing: disable termination. If we
384+
acquire `terminable`, we know this is to the exclusion of any `KillSwitch`, and
385+
are safe to return. Otherwise, some `KillSwitch` has terminated, or is in the
386+
process of terminating, this guest's execution. This means a `SIGALRM` may be
387+
pending or imminent!
388+
389+
A slightly simpler model is to consider that a `SIGALRM` may arrive in the
390+
future. This way, for correctness we only have to make sure we handle the
391+
signal we can be sent! We know that we must return to the host, and the guest
392+
fault that occurred is certainly more interesting than the guest termination,
393+
so we would like to preserve that information. There is no information or
394+
effect we want from the signal, so silence the alarm on `KillState`. This way,
395+
if we recieve the possible `SIGARLM`, we know to ignore it.
396+
397+
An important question to ask is "How do we know the possible `SIGARLM` must be
398+
ignored? Could it not be for another instance later on that thread?" The answer
399+
is, in short, "We ensure it cannot!"
400+
401+
The `SIGARLM` is thread-directed, so to be an alarm for some other reason,
402+
another instance would have to run and be terminated. To prevent this, we must
403+
prevent another instance from running. Additionally, if a `SIGALRM` is _in
404+
flight_, we need to stop and wait anyway. Since `KillSwitch` maintains a lock
405+
on `execution_domain` as long as it's attempting to terminate a guest, we can
406+
achieve both of these goals by taking, and immediately dropping, a lock on
407+
`execution_domain` before descheduling an instance.
408+
409+
Even taking this lock is interesting:
410+
0. This lock could be taken while racing a `KillSwitch`, after it has observed it may fire but before advancing to take this lock.
411+
1. This lock could be taken while racing a `KillSwitch`, after it has taken this lock.
412+
2. This lock could be taken without racing a `KillSwitch`.
413+
414+
In the first case, we've won a race on `execution_domain`, but there might be a
415+
`KillSwitch` we're blocking with this. Disarm the `KillSwitch` by updating the
416+
domain to `Terminated`, which reflects the fact that this instance has exited.
417+
418+
In the second case, descheduling until the `KillSwitch` has completed
419+
termination. The domain will be `Terminated`, and updating to `Terminated` has
420+
no effect. We simply use this lock to prevent continuting into the host until
421+
an in-flight `KillSwitch` completes.
422+
423+
In the third case, we're not racing a `KillSwitch`, and any method of exiting
424+
the guest will have disabled termination. No `KillSwitch` will observe a
425+
changed `execution_domain`, so it's not incorrect to update it to `Terminated`.
426+
427+
Taken together, this ensures that descheduling an instance serializes in-flight
428+
`KillSwitch` or prevents one from taking effect in a possibly-incorrect way, so
429+
we know this race is handled correctly.
375430

376431
### Terminated in hostcall fault
377432

lucet-concurrency-tests/src/killswitch.rs

Lines changed: 151 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,152 @@ macro_rules! killswitch_tests {
478478
})
479479
}
480480

481+
// If we terminate in the signal handler, but before termination has been disabled, a
482+
// signal will be sent to the guest. Lucet must correctly handle this case, lest the sigalrm be
483+
// delivered to disastrous effect to the host.
484+
//
485+
// This corresponds to a race during the documentation's State B -> State E "guest faults
486+
// or is terminated" transition.
487+
#[test]
488+
fn terminate_during_guest_fault() {
489+
test_c_with_instrumented_guest_entry("timeout", "fault.c", |mut inst| {
490+
let kill_switch = inst.kill_switch();
491+
492+
// *Before* termination is critical, since afterward the `KillSwitch` we test with will
493+
// just take no action.
494+
let unfortunate_time_to_terminate = inst
495+
.lock_testpoints
496+
.signal_handler_before_disabling_termination
497+
.wait_at();
498+
// Wait for the guest to reach a point we reaaallly don't want to signal at - somewhere in
499+
// the signal handler.
500+
let exiting_signal_handler = inst
501+
.lock_testpoints
502+
.signal_handler_before_returning
503+
.wait_at();
504+
// Finally, we need to know when we're ready to signal to ensure it races with.
505+
let killswitch_send_signal =
506+
inst.lock_testpoints.kill_switch_after_guest_alarm.wait_at();
507+
508+
let guest = thread::Builder::new()
509+
.name("guest".to_owned())
510+
.spawn(move || {
511+
match inst.run("main", &[0u32.into(), 0u32.into()]) {
512+
Err(Error::RuntimeFault(details)) => {
513+
assert_eq!(details.trapcode, Some(TrapCode::HeapOutOfBounds));
514+
}
515+
res => panic!("unexpected result: {:?}", res),
516+
}
517+
518+
// Check that we can reset the instance and run a normal function.
519+
inst.reset().expect("instance resets");
520+
run_onetwothree(&mut inst);
521+
})
522+
.expect("can spawn guest thread");
523+
524+
let termination_thread = unfortunate_time_to_terminate.wait_and_then(|| {
525+
let thread = thread::Builder::new()
526+
.name("killswitch".to_owned())
527+
.spawn(move || {
528+
assert_eq!(kill_switch.terminate(), Ok(KillSuccess::Signalled));
529+
})
530+
.expect("can spawn killswitch thread");
531+
killswitch_send_signal.wait();
532+
thread
533+
});
534+
535+
// Get ready to signal...
536+
// and be sure that we haven't exited the signal handler until afterward
537+
exiting_signal_handler.wait();
538+
539+
guest.join().expect("guest exits without panic");
540+
termination_thread
541+
.join()
542+
.expect("termination completes without panic");
543+
})
544+
}
545+
546+
// Variant of the above where for scheduler reasons `terminable` and
547+
// `execution_domain.lock()` happen on different sides of an instance descheduling.
548+
//
549+
// This corresponds to a race during the documentation's State B -> State E "guest faults
550+
// or is terminated" transition.
551+
//
552+
// Specifically, we want:
553+
// * signal handler fires, handling a guest fault
554+
// * timeout fires, acquiring terminable
555+
// * signal handler completes, locking in deschedule to serialize pending KillSwitch
556+
// * KillSwitch is rescheduled, then fires
557+
//
558+
// And for all of this to complete without error!
559+
#[test]
560+
fn terminate_during_guest_fault_racing_deschedule() {
561+
test_c_with_instrumented_guest_entry("timeout", "fault.c", |mut inst| {
562+
let kill_switch = inst.kill_switch();
563+
564+
// *before* termination is critical, since afterward the `KillSwitch` we test with will
565+
// just take no action.
566+
let unfortunate_time_to_terminate = inst
567+
.lock_testpoints
568+
.signal_handler_before_disabling_termination
569+
.wait_at();
570+
// we need to let the instance deschedule before our KillSwitch takes
571+
// `execution_domain`.
572+
let killswitch_acquire_termination = inst
573+
.lock_testpoints
574+
.kill_switch_after_acquiring_termination
575+
.wait_at();
576+
// and the entire test revolves around KillSwitch taking effect after
577+
// `CURRENT_INSTANCE` is cleared!
578+
let current_instance_cleared = inst
579+
.lock_testpoints
580+
.instance_after_clearing_current_instance
581+
.wait_at();
582+
583+
let guest = thread::Builder::new()
584+
.name("guest".to_owned())
585+
.spawn(move || {
586+
match inst.run("main", &[0u32.into(), 0u32.into()]) {
587+
Err(Error::RuntimeFault(details)) => {
588+
assert_eq!(details.trapcode, Some(TrapCode::HeapOutOfBounds));
589+
}
590+
res => panic!("unexpected result: {:?}", res),
591+
}
592+
593+
// Check that we can reset the instance and run a normal function.
594+
inst.reset().expect("instance resets");
595+
run_onetwothree(&mut inst);
596+
})
597+
.expect("can spawn guest thread");
598+
599+
let (termination_thread, killswitch_before_domain) = unfortunate_time_to_terminate
600+
.wait_and_then(|| {
601+
let ks_thread = thread::Builder::new()
602+
.name("killswitch".to_owned())
603+
.spawn(move || {
604+
assert_eq!(kill_switch.terminate(), Err(KillError::NotTerminable));
605+
})
606+
.expect("can spawn killswitch thread");
607+
608+
// Pause the KillSwitch thread right before it acquires `execution_domain`
609+
let killswitch_before_domain = killswitch_acquire_termination.pause();
610+
611+
(ks_thread, killswitch_before_domain)
612+
});
613+
614+
// `execution_domain` is not held, so instance descheduling will complete promptly.
615+
current_instance_cleared.wait();
616+
617+
// Resume `KillSwitch`, which will acquire `execution_domain` and terminate.
618+
killswitch_before_domain.resume();
619+
620+
guest.join().expect("guest exits without panic");
621+
termination_thread
622+
.join()
623+
.expect("termination completes without panic");
624+
})
625+
}
626+
481627
// This doesn't doesn't correspond to any state change in the documentation because it should have
482628
// no effect. The guest is in State E before, and should remain in State E after.
483629
#[test]
@@ -744,12 +890,12 @@ macro_rules! killswitch_tests {
744890

745891
ks1.join().expect("killswitch_1 did not panic");
746892

747-
// At this point the first `KillSwitch` has completed terminating the instance. Now try
748-
// again and make sure there's no boom.
749-
assert_eq!(second_kill_switch.terminate(), Err(KillError::Invalid));
750-
751893
// Allow the instance to reset and run a new function after termination.
752-
guest_exit_testpoint.wait();
894+
guest_exit_testpoint.wait_and_then(|| {
895+
// At this point the first `KillSwitch` has completed terminating the instance. Now try
896+
// again and make sure there's no boom.
897+
assert_eq!(second_kill_switch.terminate(), Err(KillError::Invalid));
898+
});
753899

754900
// And after the instance successfully runs a test function, it exits without error.
755901
guest.join().expect("guest stops running");

lucet-runtime/lucet-runtime-internals/src/instance.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,8 +1041,6 @@ impl Instance {
10411041
);
10421042
self.state = State::Running;
10431043

1044-
self.kill_state.schedule(unsafe { pthread_self() });
1045-
10461044
let res = self.with_current_instance(|i| {
10471045
i.with_signals_on(|i| {
10481046
HOST_CTX.with(|host_ctx| {
@@ -1055,6 +1053,11 @@ impl Instance {
10551053
})
10561054
});
10571055

1056+
#[cfg(feature = "concurrent_testpoints")]
1057+
self.lock_testpoints
1058+
.instance_after_clearing_current_instance
1059+
.check();
1060+
10581061
if let Err(e) = res {
10591062
// Something went wrong setting up or tearing down the signal handlers and signal
10601063
// stack. This is an error, but we don't want it to mask an error that may have arisen
@@ -1084,8 +1087,6 @@ impl Instance {
10841087
//
10851088
// The state should never be `Ready`, `Terminated`, `Yielded`, or `Transitioning` at this point
10861089

1087-
self.kill_state.deschedule();
1088-
10891090
// Set transitioning state temporarily so that we can move values out of the current state
10901091
let st = mem::replace(&mut self.state, State::Transitioning);
10911092

@@ -1181,8 +1182,12 @@ impl Instance {
11811182
Ok(())
11821183
})?;
11831184

1185+
self.kill_state.schedule(unsafe { pthread_self() });
1186+
11841187
let res = f(self);
11851188

1189+
self.kill_state.deschedule();
1190+
11861191
CURRENT_INSTANCE.with(|current_instance| {
11871192
*current_instance.borrow_mut() = None;
11881193
});

lucet-runtime/lucet-runtime-internals/src/instance/execution.rs

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,13 @@ pub struct KillState {
149149
/// `tid_change_notifier` allows functions that may cause a change in `thread_id` to wait,
150150
/// without spinning, for the signal to be processed.
151151
tid_change_notifier: Condvar,
152+
/// `ignore_alarm` indicates if a SIGALRM directed at this KillState's instance must be
153+
/// ignored. This is necessary for a specific race where a termination occurs right around when
154+
/// a Lucet guest, or hostcall the guest made, handles some other signal: if the termination
155+
/// occurs during handling of a signal that arose from guest code, a SIGALRM will either be
156+
/// pending, masked by Lucet's sigaction's signal mask, OR a SIGLARM will be imminent after
157+
/// handling the signal.
158+
ignore_alarm: AtomicBool,
152159
#[cfg(feature = "concurrent_testpoints")]
153160
/// When testing race permutations, `KillState` keeps a reference to the `LockTestpoints` its
154161
/// associated instance holds.
@@ -312,6 +319,7 @@ impl Default for KillState {
312319
tid_change_notifier: Condvar::new(),
313320
execution_domain: Mutex::new(Domain::Pending),
314321
thread_id: Mutex::new(None),
322+
ignore_alarm: AtomicBool::new(false),
315323
}
316324
}
317325
}
@@ -330,6 +338,7 @@ impl KillState {
330338
tid_change_notifier: Condvar::new(),
331339
execution_domain: Mutex::new(Domain::Pending),
332340
thread_id: Mutex::new(None),
341+
ignore_alarm: AtomicBool::new(false),
333342
lock_testpoints,
334343
}
335344
}
@@ -342,14 +351,22 @@ impl KillState {
342351
self.terminable.store(true, Ordering::SeqCst);
343352
}
344353

345-
pub fn disable_termination(&self) {
346-
self.terminable.store(false, Ordering::SeqCst);
354+
pub fn disable_termination(&self) -> bool {
355+
self.terminable.swap(false, Ordering::SeqCst)
347356
}
348357

349358
pub fn terminable_ptr(&self) -> *const AtomicBool {
350359
&self.terminable as *const AtomicBool
351360
}
352361

362+
pub fn silence_alarm(&self) -> bool {
363+
self.ignore_alarm.swap(true, Ordering::SeqCst)
364+
}
365+
366+
pub fn alarm_active(&self) -> bool {
367+
!self.ignore_alarm.load(Ordering::SeqCst)
368+
}
369+
353370
/// Set the execution domain to signify that we are currently executing a hostcall.
354371
///
355372
/// This method will panic if the execution domain is currently marked as anything but
@@ -450,6 +467,27 @@ impl KillState {
450467
pub fn deschedule(&self) {
451468
*self.thread_id.lock().unwrap() = None;
452469
self.tid_change_notifier.notify_all();
470+
471+
// If a guest is being descheduled, this lock is load-bearing in two ways:
472+
// * If a KillSwitch is in flight and already holds `execution_domain`, we must wait for
473+
// it to complete. This prevents a SIGALRM from being sent at some point later in host
474+
// execution.
475+
// * If a KillSwitch has aqcuired `terminable`, but not `execution_domain`, we may win the
476+
// race for this lock. We don't know when the KillSwitch will try to check
477+
// `execution_domain`. Holding the lock, we can update it to `Terminated` - this reflects
478+
// that the instance has exited, but also signals that the KillSwitch should take no
479+
// effect.
480+
//
481+
// This must occur *after* notifing `tid_change_notifier` so that we indicate to a
482+
// `KillSwitch` that the instance was actually descheduled, if it was terminating a guest.
483+
//
484+
// If any other state is being descheduled, either the instance faulted in another domain,
485+
// or a hostcall called `yield`, and we must preserve the `Hostcall` domain, so don't
486+
// change it.
487+
let mut execution_domain = self.execution_domain.lock().unwrap();
488+
if let Domain::Guest = *execution_domain {
489+
*execution_domain = Domain::Terminated;
490+
}
453491
}
454492
}
455493

@@ -593,6 +631,10 @@ impl KillSwitch {
593631
unsafe {
594632
pthread_kill(thread_id, SIGALRM);
595633
}
634+
635+
#[cfg(feature = "concurrent_testpoints")]
636+
state.lock_testpoints.kill_switch_after_guest_alarm.check();
637+
596638
// wait for the SIGALRM handler to deschedule the instance
597639
//
598640
// this should never actually loop, which would indicate the instance

0 commit comments

Comments
 (0)