use std::sync::Mutex;
use std::time::Duration;
use subsecond::JumpTable;
pub fn devlog(line: &str) {
#[cfg(target_os = "android")]
{
unsafe extern "C" {
fn __android_log_write(
prio: std::os::raw::c_int,
tag: *const std::os::raw::c_char,
text: *const std::os::raw::c_char,
) -> std::os::raw::c_int;
}
const ANDROID_LOG_INFO: std::os::raw::c_int = 4;
let tag = b"whisker-dev\0";
let mut buf: Vec<u8> = Vec::with_capacity(line.len() + 1);
buf.extend_from_slice(line.as_bytes());
buf.push(0);
unsafe {
__android_log_write(
ANDROID_LOG_INFO,
tag.as_ptr() as *const _,
buf.as_ptr() as *const _,
);
}
}
#[cfg(target_os = "ios")]
{
unsafe extern "C" {
fn syslog(priority: std::os::raw::c_int, fmt: *const std::os::raw::c_char, ...);
}
const LOG_INFO: std::os::raw::c_int = 6;
let mut buf: Vec<u8> = Vec::with_capacity(line.len() + 16);
buf.extend_from_slice(b"[whisker-dev] ");
buf.extend_from_slice(line.as_bytes());
buf.push(0);
let fmt = b"%s\0";
unsafe {
syslog(LOG_INFO, fmt.as_ptr() as *const _, buf.as_ptr());
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
eprintln!("[whisker-dev] {line}");
}
}
static PENDING: Mutex<Option<JumpTable>> = Mutex::new(None);
pub fn take_pending_patch() -> Option<JumpTable> {
PENDING.lock().ok().and_then(|mut p| p.take())
}
pub fn start_receiver() {
let addr = std::env::var("WHISKER_DEV_ADDR")
.ok()
.filter(|a| !a.is_empty())
.unwrap_or_else(|| "127.0.0.1:9876".to_string());
devlog(&format!(
"hot-reload receiver targeting ws://{addr}/whisker-dev",
));
std::thread::Builder::new()
.name("whisker-hot-reload".to_string())
.spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
devlog(&format!("couldn't build tokio runtime: {e}"));
return;
}
};
rt.block_on(client_loop(addr));
})
.expect("spawn whisker-hot-reload thread");
}
async fn client_loop(addr: String) {
let url = format!("ws://{addr}/whisker-dev");
loop {
match tokio_tungstenite::connect_async(&url).await {
Ok((ws, _)) => {
devlog(&format!("connected: {url}"));
if let Err(e) = handle_session(ws).await {
devlog(&format!("session ended: {e}"));
}
}
Err(e) => devlog(&format!("connect {url} failed: {e}")),
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
fn device_aslr_reference() -> u64 {
subsecond::aslr_reference() as u64
}
async fn handle_session<S>(
mut ws: tokio_tungstenite::WebSocketStream<S>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
let hello = serde_json::json!({
"kind": "hello",
"aslr_reference": device_aslr_reference(),
})
.to_string();
devlog(&format!(
"sending hello with aslr_reference={:#x}",
device_aslr_reference()
));
ws.send(Message::Text(hello)).await?;
loop {
tokio::select! {
lines = crate::log_capture::drain_pending_logs() => {
for line in lines {
let frame = serde_json::json!({
"kind": "log",
"stream": line.stream.as_wire(),
"line": line.text,
"ts_micros": line.ts_micros.to_string(),
})
.to_string();
ws.send(Message::Text(frame)).await?;
}
}
msg = ws.next() => {
let Some(msg) = msg else { return Ok(()); };
match msg? {
Message::Binary(bytes) => handle_patch_frame(&bytes),
Message::Close(_) => return Ok(()),
_ => {}
}
}
}
}
}
fn handle_patch_frame(bytes: &[u8]) {
devlog(&format!("patch frame received ({} bytes)", bytes.len()));
let (mut table, dylib_bytes) = match parse_patch_frame(bytes) {
Ok(parsed) => parsed,
Err(e) => {
devlog(&format!("malformed patch frame: {e}"));
return;
}
};
devlog(&format!(
"frame parsed (map={} entries, dylib={} bytes)",
table.map.len(),
dylib_bytes.len(),
));
let local = match materialise_patch_dylib(dylib_bytes) {
Ok(p) => p,
Err(e) => {
devlog(&format!("could not materialise patch dylib: {e}"));
return;
}
};
devlog(&format!("patch dylib materialised at {}", local.display()));
table.lib = local;
if let Ok(mut p) = PENDING.lock() {
*p = Some(table);
devlog("patch queued");
}
whisker_runtime::host_wake::wake_runtime();
}
fn materialise_patch_dylib(
bytes: &[u8],
) -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
use std::sync::atomic::{AtomicU64, Ordering};
let dir = patch_cache_dir().ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
"could not resolve a writable cache dir".into()
})?;
std::fs::create_dir_all(&dir)?;
static SEQ: AtomicU64 = AtomicU64::new(0);
let n = SEQ.fetch_add(1, Ordering::Relaxed);
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let path = dir.join(format!("patch-{ts}-{n}.so"));
std::fs::write(&path, bytes)?;
Ok(path)
}
fn patch_cache_dir() -> Option<std::path::PathBuf> {
#[cfg(target_os = "android")]
{
let cmdline = std::fs::read_to_string("/proc/self/cmdline").ok()?;
let pkg = cmdline.split('\0').next().unwrap_or("").trim().to_string();
if !pkg.is_empty() {
return Some(std::path::PathBuf::from(format!(
"/data/data/{pkg}/cache/whisker-patches"
)));
}
None
}
#[cfg(not(target_os = "android"))]
{
Some(std::env::temp_dir().join("whisker-patches"))
}
}
#[derive(Debug, serde::Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Header {
Patch {
#[serde(deserialize_with = "deserialize_jump_table")]
table: JumpTable,
},
}
fn deserialize_jump_table<'de, D>(d: D) -> Result<JumpTable, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
use std::path::PathBuf;
use subsecond_types::AddressMap;
#[derive(Deserialize)]
struct Wire {
lib: PathBuf,
map: Vec<(u64, u64)>,
aslr_reference: u64,
new_base_address: u64,
ifunc_count: u64,
}
let w = Wire::deserialize(d)?;
let mut map = AddressMap::default();
map.reserve(w.map.len());
for (k, v) in w.map {
map.insert(k, v);
}
Ok(JumpTable {
lib: w.lib,
map,
aslr_reference: w.aslr_reference,
new_base_address: w.new_base_address,
ifunc_count: w.ifunc_count,
})
}
fn parse_patch_frame(
bytes: &[u8],
) -> Result<(JumpTable, &[u8]), Box<dyn std::error::Error + Send + Sync>> {
if bytes.len() < 8 {
return Err(format!("frame too short ({} bytes, need ≥8)", bytes.len()).into());
}
let json_len = u64::from_be_bytes(bytes[..8].try_into().unwrap()) as usize;
let header_end = 8usize.checked_add(json_len).ok_or("json_len overflow")?;
if bytes.len() < header_end {
return Err(format!(
"frame truncated: header claims {} json bytes but only {} available",
json_len,
bytes.len() - 8,
)
.into());
}
let header: Header = serde_json::from_slice(&bytes[8..header_end]).map_err(
|e| -> Box<dyn std::error::Error + Send + Sync> {
format!("parse json header: {e}").into()
},
)?;
let Header::Patch { table } = header;
Ok((table, &bytes[header_end..]))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_frame(json: &str, dylib: &[u8]) -> Vec<u8> {
let json_bytes = json.as_bytes();
let mut frame = Vec::with_capacity(8 + json_bytes.len() + dylib.len());
frame.extend_from_slice(&(json_bytes.len() as u64).to_be_bytes());
frame.extend_from_slice(json_bytes);
frame.extend_from_slice(dylib);
frame
}
#[test]
fn parses_a_minimal_patch_frame() {
let json = r#"{
"kind": "patch",
"table": {
"lib": "/tmp/some-patch.dylib",
"map": [],
"aslr_reference": 4294967296,
"new_base_address": 8589934592,
"ifunc_count": 0
}
}"#;
let frame = make_frame(json, b"");
let (table, dylib) = parse_patch_frame(&frame).expect("should parse");
assert_eq!(table.lib.to_string_lossy(), "/tmp/some-patch.dylib",);
assert_eq!(table.aslr_reference, 0x1_0000_0000);
assert_eq!(table.new_base_address, 0x2_0000_0000);
assert_eq!(table.ifunc_count, 0);
assert!(table.map.is_empty());
assert!(dylib.is_empty());
}
#[test]
fn parses_a_frame_with_a_non_empty_address_map_and_dylib_bytes() {
let json = r#"{
"kind": "patch",
"table": {
"lib": "/tmp/p.so",
"map": [[100, 200], [300, 400]],
"aslr_reference": 0,
"new_base_address": 0,
"ifunc_count": 0
}
}"#;
let dylib_bytes = b"\x00\x01\x02\x03";
let frame = make_frame(json, dylib_bytes);
let (table, dylib) = parse_patch_frame(&frame).expect("should parse");
assert_eq!(table.map.len(), 2);
assert_eq!(table.map.get(&100), Some(&200));
assert_eq!(table.map.get(&300), Some(&400));
assert_eq!(dylib, dylib_bytes);
}
#[test]
fn materialise_patch_dylib_writes_bytes_to_cache_and_returns_path() {
let payload = b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00";
let path = materialise_patch_dylib(payload).expect("write");
let read_back = std::fs::read(&path).unwrap();
assert_eq!(read_back, payload);
let _ = std::fs::remove_file(&path);
}
#[test]
fn rejects_unknown_envelope_kind() {
let frame = make_frame(r#"{ "kind": "frobnicate" }"#, b"");
assert!(parse_patch_frame(&frame).is_err());
}
#[test]
fn rejects_truncated_frame() {
assert!(parse_patch_frame(&[0u8; 5]).is_err());
}
#[test]
fn rejects_frame_whose_header_length_overruns_the_payload() {
let mut frame = Vec::new();
frame.extend_from_slice(&100u64.to_be_bytes());
assert!(parse_patch_frame(&frame).is_err());
}
#[test]
fn take_pending_returns_none_when_queue_is_empty() {
let _ = take_pending_patch();
assert!(take_pending_patch().is_none());
}
}