Introduce ZramWriteback

This implements the basic logic of periodical zram writeback.
mmd marks pages in zram as idle which have been in zram for more than a
specified duration between min_idle and max_idle.

The logic mainly comes from swap_management of ChromeOS.

Bug: 375432468
Test: atest mmd_unit_tests

Change-Id: I2d2a5bcabd7f2632d5707f001547d5052f3f8a3a
diff --git a/Android.bp b/Android.bp
index 9c0c74c..047bd46 100644
--- a/Android.bp
+++ b/Android.bp
@@ -8,14 +8,28 @@
         "src/main.rs",
     ],
     rustlibs: [
+        "libanyhow",
         "libbinder_rs",
         "liblogger",
         "liblog_rust",
+        "libmmd",
         "libmmd_flags_rust",
+        "libmockall",
         "mmd_aidl_interface-rust",
     ],
 }
 
+rust_defaults {
+    name: "libmmd_defaults",
+    srcs: [
+        "src/lib.rs",
+    ],
+    rustlibs: [
+        "libmockall",
+        "libthiserror",
+    ],
+}
+
 rust_binary {
     name: "mm_daemon",
     defaults: ["mmd_defaults"],
@@ -23,6 +37,13 @@
     init_rc: ["mmd.rc"],
 }
 
+rust_library {
+    name: "libmmd",
+    crate_name: "mmd",
+    defaults: ["libmmd_defaults"],
+    host_supported: true,
+}
+
 rust_test {
     name: "mmd_unit_tests",
     defaults: ["mmd_defaults"],
@@ -30,6 +51,12 @@
     auto_gen_config: true,
 }
 
+rust_test_host {
+    name: "libmmd_unit_tests",
+    defaults: ["libmmd_defaults"],
+    test_suites: ["general-tests"],
+}
+
 aconfig_declarations {
     name: "mmd_flags",
     package: "android.mmd.flags",
diff --git a/TEST_MAPPING b/TEST_MAPPING
index b738f38..88354a1 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -2,6 +2,9 @@
   "postsubmit": [
     {
       "name": "mmd_unit_tests"
+    },
+    {
+      "name": "libmmd_unit_tests"
     }
   ]
 }
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..66db064
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,20 @@
+// Copyright 2024, The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! This is library part of mmd which does not depends on Android specific APIs.
+
+pub mod os;
+#[cfg(test)]
+mod test_helper;
+pub mod zram;
diff --git a/src/main.rs b/src/main.rs
index 4d66a12..187023e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -36,7 +36,7 @@
         }
     }
 
-    let mmd_service = service::MmdService;
+    let mmd_service = service::MmdService::new();
     let mmd_service_binder = BnMmd::new_binder(mmd_service, BinderFeatures::default());
     binder::add_service("mmd", mmd_service_binder.as_binder()).expect("register service");
 
diff --git a/src/os.rs b/src/os.rs
new file mode 100644
index 0000000..6c132a9
--- /dev/null
+++ b/src/os.rs
@@ -0,0 +1,50 @@
+// Copyright 2024, The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! This module provides mockable interface and its implementation of os
+//! operations.
+
+use std::io;
+use std::path::Path;
+
+/// [Ops] is the mockable interface for accesses to system global os properties
+/// (e.g. read/write on procfs, sysfs).
+///
+/// generic functions with AsRef requires #[mockall::concretize] which has a
+/// downside that we can't use `.with` but need to use `.withf` or `.withf_st`.
+///
+/// https://docs.rs/mockall/latest/mockall/attr.concretize.html
+///
+/// `mod test_helpers` defines helper macros to generate matcher for `.withf`.
+#[cfg_attr(test, mockall::automock)]
+pub trait Ops {
+    /// mockable wrapper for [std::fs::read_to_string].
+    #[mockall::concretize]
+    fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String>;
+    /// mockable wrapper for [std::fs::write].
+    #[mockall::concretize]
+    fn write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> io::Result<()>;
+}
+
+/// Real implementation of [Ops].
+pub struct OpsImpl;
+
+impl Ops for OpsImpl {
+    fn read_to_string<P: AsRef<Path>>(path: P) -> io::Result<String> {
+        std::fs::read_to_string(path)
+    }
+    fn write<P: AsRef<Path>, C: AsRef<[u8]>>(path: P, contents: C) -> io::Result<()> {
+        std::fs::write(path, contents)
+    }
+}
diff --git a/src/service.rs b/src/service.rs
index b51aee5..2634fb0 100644
--- a/src/service.rs
+++ b/src/service.rs
@@ -12,18 +12,43 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
+use std::sync::Mutex;
+use std::time::Instant;
+
 use binder::Interface;
 use binder::Result as BinderResult;
-use log::info;
+use log::error;
 use mmd_aidl_interface::aidl::android::os::IMmd::IMmd;
 
-pub struct MmdService;
+use mmd::os::OpsImpl;
+use mmd::zram::writeback::Error as ZramWritebackError;
+use mmd::zram::writeback::ZramWriteback;
+
+pub struct MmdService {
+    zram_writeback: Mutex<ZramWriteback>,
+}
+
+impl MmdService {
+    pub fn new() -> Self {
+        Self { zram_writeback: Mutex::new(ZramWriteback::new()) }
+    }
+}
 
 impl Interface for MmdService {}
 
 impl IMmd for MmdService {
     fn doZramMaintenance(&self) -> BinderResult<()> {
-        info!("doZramMaintenance");
+        let mut zram_writeback = self.zram_writeback.lock().expect("mmd aborts on panics");
+        let params = load_zram_writeback_params();
+        match zram_writeback.mark_and_flush_pages::<OpsImpl>(&params, Instant::now()) {
+            Ok(_) | Err(ZramWritebackError::BackoffTime) => {}
+            Err(e) => error!("failed to zram writeback: {e:?}"),
+        }
         Ok(())
     }
 }
+
+fn load_zram_writeback_params() -> mmd::zram::writeback::Params {
+    // TODO: load params from system properties.
+    mmd::zram::writeback::Params::default()
+}
diff --git a/src/test_helper.rs b/src/test_helper.rs
new file mode 100644
index 0000000..bad369c
--- /dev/null
+++ b/src/test_helper.rs
@@ -0,0 +1,49 @@
+// Copyright 2024, The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! This module provides helpers for tests.
+//!
+//! `*_params` are the helper functions to use with `.withf` of [crate::os::MockOps].
+
+use std::path::Path;
+use std::sync::Mutex;
+
+/// Mutex to synchronize tests using [crate::os::MockOps].
+///
+/// mockall for static functions requires synchronization.
+///
+/// https://docs.rs/mockall/latest/mockall/#static-methods
+pub static OPS_MTX: Mutex<()> = Mutex::new(());
+
+/// This is helper function for [Ops::write].
+pub fn write_params<P: AsRef<Path>, C: AsRef<[u8]>>(
+    p: P,
+    c: C,
+) -> impl Fn(&dyn AsRef<Path>, &dyn AsRef<[u8]>) -> bool {
+    move |path, contents| path.as_ref() == p.as_ref() && contents.as_ref() == c.as_ref()
+}
+
+/// This is helper function for [Ops::write]. This consider the path only.
+pub fn write_path_params<P: AsRef<Path>>(
+    p: P,
+) -> impl Fn(&dyn AsRef<Path>, &dyn AsRef<[u8]>) -> bool {
+    move |path, _| path.as_ref() == p.as_ref()
+}
+
+/// This is helper function for functions with single `AsRef<Path>` argument.
+///
+/// * [Ops::read_to_string]
+pub fn path_params<P: AsRef<Path>>(p: P) -> impl Fn(&dyn AsRef<Path>) -> bool {
+    move |path| path.as_ref() == p.as_ref()
+}
diff --git a/src/zram.rs b/src/zram.rs
new file mode 100644
index 0000000..0685f9e
--- /dev/null
+++ b/src/zram.rs
@@ -0,0 +1,18 @@
+// Copyright 2024, The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! This module provides policies to manage zram features.
+
+mod idle;
+pub mod writeback;
diff --git a/src/zram/idle.rs b/src/zram/idle.rs
new file mode 100644
index 0000000..8853d16
--- /dev/null
+++ b/src/zram/idle.rs
@@ -0,0 +1,263 @@
+// Copyright 2024, The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use std::time::Duration;
+
+use crate::os::Ops;
+
+pub const ZRAM_IDLE_PATH: &str = "/sys/block/zram0/idle";
+pub const MEMINFO_PATH: &str = "/proc/meminfo";
+
+/// Sets idle duration in seconds to "/sys/block/zram0/idle".
+///
+/// Fractions of a second are truncated.
+pub fn set_zram_idle_time<O: Ops>(idle_age: Duration) -> std::io::Result<()> {
+    O::write(ZRAM_IDLE_PATH, idle_age.as_secs().to_string())
+}
+
+/// This parses the content of "/proc/meminfo" and returns the number of "MemTotal" and
+/// "MemAvailable".
+///
+/// This does not care about the unit, because the user `calculate_idle_time()` use the values to
+/// calculate memory utilization rate. The unit should be always "kB".
+///
+/// This returns `None` if this fails to parse the content.
+fn parse_meminfo(content: &str) -> Option<(u64, u64)> {
+    let mut total = None;
+    let mut available = None;
+    for line in content.split("\n") {
+        let container = if line.contains("MemTotal:") {
+            &mut total
+        } else if line.contains("MemAvailable:") {
+            &mut available
+        } else {
+            continue;
+        };
+        let Some(number_str) = line.split_whitespace().nth(1) else {
+            continue;
+        };
+        let Ok(number) = number_str.parse::<u64>() else {
+            continue;
+        };
+        *container = Some(number);
+    }
+    if let (Some(total), Some(available)) = (total, available) {
+        Some((total, available))
+    } else {
+        None
+    }
+}
+
+/// Error from [calculate_idle_time].
+#[derive(Debug, thiserror::Error)]
+pub enum CalculateError {
+    #[error("min_idle is longer than max_idle")]
+    InvalidMinAndMax,
+    #[error("failed to parse meminfo")]
+    InvalidMeminfo,
+    #[error("failed to read meminfo: {0}")]
+    ReadMeminfo(std::io::Error),
+}
+
+/// Calculates idle duration from min_idle and max_idle using meminfo.
+pub fn calculate_idle_time<O: Ops>(
+    min_idle: Duration,
+    max_idle: Duration,
+) -> std::result::Result<Duration, CalculateError> {
+    if min_idle > max_idle {
+        return Err(CalculateError::InvalidMinAndMax);
+    }
+    let content = match O::read_to_string(MEMINFO_PATH) {
+        Ok(v) => v,
+        Err(e) => return Err(CalculateError::ReadMeminfo(e)),
+    };
+    let (total, available) = match parse_meminfo(&content) {
+        Some((total, available)) if total > 0 => (total, available),
+        _ => {
+            // Fallback to use the safest value.
+            return Err(CalculateError::InvalidMeminfo);
+        }
+    };
+
+    let mem_utilization = 1.0 - (available as f64) / (total as f64);
+
+    // Exponentially decay the age vs. memory utilization. The reason we choose exponential decay is
+    // because we want to do as little work as possible when the system is under very low memory
+    // pressure. As pressure increases we want to start aggressively shrinking our idle age to force
+    // newer pages to be written back/recompressed.
+    const LAMBDA: f64 = 5.0;
+    let seconds = ((max_idle - min_idle).as_secs() as f64)
+        * std::f64::consts::E.powf(-LAMBDA * mem_utilization)
+        + (min_idle.as_secs() as f64);
+
+    Ok(Duration::from_secs(seconds as u64))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::os::MockOps;
+    use crate::test_helper::path_params;
+    use crate::test_helper::write_params;
+    use crate::test_helper::OPS_MTX;
+
+    #[test]
+    fn test_set_zram_idle_time() {
+        let _m = OPS_MTX.lock();
+        let mock = MockOps::write_context();
+        mock.expect().withf(write_params(ZRAM_IDLE_PATH, "3600")).returning(|_, _| Ok(()));
+
+        assert!(set_zram_idle_time::<MockOps>(Duration::from_secs(3600)).is_ok());
+    }
+
+    #[test]
+    fn test_set_zram_idle_time_in_seconds() {
+        let _m = OPS_MTX.lock();
+        let mock = MockOps::write_context();
+        mock.expect().withf(write_params(ZRAM_IDLE_PATH, "3600")).returning(|_, _| Ok(()));
+
+        assert!(set_zram_idle_time::<MockOps>(Duration::from_millis(3600567)).is_ok());
+    }
+
+    #[test]
+    fn test_parse_meminfo() {
+        let content = "MemTotal:       123456789 kB
+MemFree:        12345 kB
+MemAvailable:   67890 kB
+    ";
+        assert_eq!(parse_meminfo(content).unwrap(), (123456789, 67890));
+    }
+
+    #[test]
+    fn test_parse_meminfo_invalid_format() {
+        // empty
+        assert!(parse_meminfo("").is_none());
+        // no number
+        let content = "MemTotal:
+MemFree:        12345 kB
+MemAvailable:   67890 kB
+    ";
+        assert!(parse_meminfo(content).is_none());
+        // no number
+        let content = "MemTotal:       kB
+MemFree:        12345 kB
+MemAvailable:   67890 kB
+    ";
+        assert!(parse_meminfo(content).is_none());
+        // total memory missing
+        let content = "MemFree:        12345 kB
+MemAvailable:   67890 kB
+    ";
+        assert!(parse_meminfo(content).is_none());
+        // available memory missing
+        let content = "MemTotal:       123456789 kB
+MemFree:        12345 kB
+    ";
+        assert!(parse_meminfo(content).is_none());
+    }
+
+    #[test]
+    fn test_calculate_idle_time() {
+        let _m = OPS_MTX.lock();
+        let mock = MockOps::read_to_string_context();
+        let meminfo = "MemTotal: 8144296 kB
+    MemAvailable: 346452 kB";
+        mock.expect().withf(path_params(MEMINFO_PATH)).returning(|_| Ok(meminfo.to_string()));
+
+        assert_eq!(
+            calculate_idle_time::<MockOps>(Duration::from_secs(72000), Duration::from_secs(90000))
+                .unwrap(),
+            Duration::from_secs(72150)
+        );
+    }
+
+    #[test]
+    fn test_calculate_idle_time_same_min_max() {
+        let _m = OPS_MTX.lock();
+        let mock = MockOps::read_to_string_context();
+        let meminfo = "MemTotal: 8144296 kB
+    MemAvailable: 346452 kB";
+        mock.expect().withf(path_params(MEMINFO_PATH)).returning(|_| Ok(meminfo.to_string()));
+
+        assert_eq!(
+            calculate_idle_time::<MockOps>(Duration::from_secs(90000), Duration::from_secs(90000))
+                .unwrap(),
+            Duration::from_secs(90000)
+        );
+    }
+
+    #[test]
+    fn test_calculate_idle_time_min_is_bigger_than_max() {
+        assert!(matches!(
+            calculate_idle_time::<MockOps>(Duration::from_secs(90000), Duration::from_secs(72000)),
+            Err(CalculateError::InvalidMinAndMax)
+        ));
+    }
+
+    #[test]
+    fn test_calculate_idle_time_no_available() {
+        let _m = OPS_MTX.lock();
+        let mock = MockOps::read_to_string_context();
+        let meminfo = "MemTotal: 8144296 kB
+    MemAvailable: 0 kB";
+        mock.expect().withf(path_params(MEMINFO_PATH)).returning(|_| Ok(meminfo.to_string()));
+
+        assert_eq!(
+            calculate_idle_time::<MockOps>(Duration::from_secs(72000), Duration::from_secs(90000))
+                .unwrap(),
+            Duration::from_secs(72121)
+        );
+    }
+
+    #[test]
+    fn test_calculate_idle_time_meminfo_fail() {
+        let _m = OPS_MTX.lock();
+        let mock = MockOps::read_to_string_context();
+        mock.expect()
+            .withf(path_params(MEMINFO_PATH))
+            .returning(|_| Err(std::io::Error::other("error")));
+
+        assert!(matches!(
+            calculate_idle_time::<MockOps>(Duration::from_secs(72000), Duration::from_secs(90000)),
+            Err(CalculateError::ReadMeminfo(_))
+        ));
+    }
+
+    #[test]
+    fn test_calculate_idle_time_invalid_meminfo() {
+        let _m = OPS_MTX.lock();
+        let mock = MockOps::read_to_string_context();
+        let meminfo = "";
+        mock.expect().withf(path_params(MEMINFO_PATH)).returning(|_| Ok(meminfo.to_string()));
+
+        assert!(matches!(
+            calculate_idle_time::<MockOps>(Duration::from_secs(72000), Duration::from_secs(90000)),
+            Err(CalculateError::InvalidMeminfo)
+        ));
+    }
+
+    #[test]
+    fn test_calculate_idle_time_zero_total_memory() {
+        let _m = OPS_MTX.lock();
+        let mock = MockOps::read_to_string_context();
+        let meminfo = "MemTotal: 0 kB
+    MemAvailable: 346452 kB";
+        mock.expect().withf(path_params(MEMINFO_PATH)).returning(|_| Ok(meminfo.to_string()));
+
+        assert!(matches!(
+            calculate_idle_time::<MockOps>(Duration::from_secs(72000), Duration::from_secs(90000)),
+            Err(CalculateError::InvalidMeminfo)
+        ));
+    }
+}
diff --git a/src/zram/writeback.rs b/src/zram/writeback.rs
new file mode 100644
index 0000000..80a7474
--- /dev/null
+++ b/src/zram/writeback.rs
@@ -0,0 +1,152 @@
+// Copyright 2024, The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! This module provides policy to manage zram writeback feature.
+//!
+//! See "writeback" section in the kernel document for details.
+//!
+//! https://www.kernel.org/doc/Documentation/blockdev/zram.txt
+
+#[cfg(test)]
+mod tests;
+
+use std::time::Duration;
+use std::time::Instant;
+
+use crate::os::Ops;
+use crate::zram::idle::calculate_idle_time;
+use crate::zram::idle::set_zram_idle_time;
+
+const ZRAM_WRITEBACK_PATH: &str = "/sys/block/zram0/writeback";
+
+/// Error from [ZramWriteback].
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+    /// writeback too frequently
+    #[error("writeback too frequently")]
+    BackoffTime,
+    /// failure on setting zram idle
+    #[error("calculate zram idle {0}")]
+    CalculateIdle(#[from] crate::zram::idle::CalculateError),
+    /// failure on setting zram idle
+    #[error("set zram idle {0}")]
+    MarkIdle(std::io::Error),
+    /// failure on writing to /sys/block/zram0/writeback
+    #[error("writeback: {0}")]
+    Writeback(std::io::Error),
+}
+
+type Result<T> = std::result::Result<T, Error>;
+
+/// The parameters for zram writeback.
+pub struct Params {
+    /// The backoff time since last writeback.
+    pub backoff_duration: Duration,
+    /// The minimum idle duration to writeback. This is used for [calculate_idle_time].
+    pub min_idle: Duration,
+    /// The maximum idle duration to writeback. This is used for [calculate_idle_time].
+    pub max_idle: Duration,
+    /// Whether writeback huge and idle pages or not.
+    pub huge_idle: bool,
+    /// Whether writeback idle pages or not.
+    pub idle: bool,
+    /// Whether writeback huge pages or not.
+    pub huge: bool,
+}
+
+impl Default for Params {
+    fn default() -> Self {
+        Self {
+            // 10 minutes
+            backoff_duration: Duration::from_secs(600),
+            // 20 hours
+            min_idle: Duration::from_secs(20 * 3600),
+            // 25 hours
+            max_idle: Duration::from_secs(25 * 3600),
+            huge_idle: true,
+            idle: true,
+            huge: true,
+        }
+    }
+}
+
+enum Mode {
+    HugeIdle,
+    Idle,
+    Huge,
+}
+
+/// ZramWriteback manages zram writeback policies.
+pub struct ZramWriteback {
+    last_writeback_at: Option<Instant>,
+}
+
+impl ZramWriteback {
+    /// Creates a new [ZramWriteback].
+    pub fn new() -> Self {
+        Self { last_writeback_at: None }
+    }
+
+    /// Writes back idle or huge zram pages to disk.
+    pub fn mark_and_flush_pages<O: Ops>(&mut self, params: &Params, now: Instant) -> Result<()> {
+        if let Some(last_at) = self.last_writeback_at {
+            if now - last_at < params.backoff_duration {
+                return Err(Error::BackoffTime);
+            }
+        }
+
+        if params.huge_idle {
+            self.writeback::<O>(params, Mode::HugeIdle, now)?;
+        }
+        if params.idle {
+            self.writeback::<O>(params, Mode::Idle, now)?;
+        }
+        if params.huge {
+            self.writeback::<O>(params, Mode::Huge, now)?;
+        }
+
+        Ok(())
+    }
+
+    fn writeback<O: Ops>(&mut self, params: &Params, mode: Mode, now: Instant) -> Result<()> {
+        match mode {
+            Mode::HugeIdle | Mode::Idle => {
+                let idle_age = calculate_idle_time::<O>(params.min_idle, params.max_idle)?;
+                // TODO: adjust the idle_age by suspend duration.
+                set_zram_idle_time::<O>(idle_age).map_err(Error::MarkIdle)?;
+            }
+            Mode::Huge => {}
+        }
+
+        let mode = match mode {
+            Mode::HugeIdle => "huge_idle",
+            Mode::Idle => "idle",
+            Mode::Huge => "huge",
+        };
+
+        O::write(ZRAM_WRITEBACK_PATH, mode).map_err(Error::Writeback)?;
+
+        self.last_writeback_at = Some(now);
+
+        Ok(())
+    }
+}
+
+// TODO: remove this when we add parameters to [ZramWriteback::new].
+// This is just to suppress clippy::new_without_default.
+impl Default for ZramWriteback {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/src/zram/writeback/tests.rs b/src/zram/writeback/tests.rs
new file mode 100644
index 0000000..6728d12
--- /dev/null
+++ b/src/zram/writeback/tests.rs
@@ -0,0 +1,263 @@
+// Copyright 2024, The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+use super::*;
+use crate::os::MockOps;
+use crate::test_helper::path_params;
+use crate::test_helper::write_params;
+use crate::test_helper::write_path_params;
+use crate::test_helper::OPS_MTX;
+use crate::zram::idle::MEMINFO_PATH;
+use crate::zram::idle::ZRAM_IDLE_PATH;
+use mockall::Sequence;
+
+fn setup_default_meminfo(rmock: &crate::os::__mock_MockOps_Ops::__read_to_string::Context) {
+    let meminfo = "MemTotal: 8144296 kB
+        MemAvailable: 346452 kB";
+    rmock.expect().withf(path_params(MEMINFO_PATH)).returning(|_| Ok(meminfo.to_string()));
+}
+
+#[test]
+fn mark_and_flush_pages() {
+    let _m = OPS_MTX.lock();
+    let mut seq = Sequence::new();
+    let wmock = MockOps::write_context();
+    let rmock = MockOps::read_to_string_context();
+    setup_default_meminfo(&rmock);
+    let params = Params::default();
+    let mut zram_writeback = ZramWriteback::new();
+
+    wmock
+        .expect()
+        .withf(write_path_params(ZRAM_IDLE_PATH))
+        .times(1)
+        .in_sequence(&mut seq)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "huge_idle"))
+        .times(1)
+        .in_sequence(&mut seq)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_path_params(ZRAM_IDLE_PATH))
+        .times(1)
+        .in_sequence(&mut seq)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "idle"))
+        .times(1)
+        .in_sequence(&mut seq)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "huge"))
+        .times(1)
+        .in_sequence(&mut seq)
+        .returning(|_, _| Ok(()));
+
+    assert!(zram_writeback.mark_and_flush_pages::<MockOps>(&params, Instant::now()).is_ok());
+}
+
+#[test]
+fn mark_and_flush_pages_before_backoff() {
+    let _m = OPS_MTX.lock();
+    let wmock = MockOps::write_context();
+    wmock.expect().returning(|_, _| Ok(()));
+    let rmock = MockOps::read_to_string_context();
+    setup_default_meminfo(&rmock);
+    let params = Params { backoff_duration: Duration::from_secs(100), ..Default::default() };
+    let base_time = Instant::now();
+    let mut zram_writeback = ZramWriteback::new();
+    assert!(zram_writeback.mark_and_flush_pages::<MockOps>(&params, base_time).is_ok());
+    wmock.checkpoint();
+
+    wmock.expect().times(0);
+
+    assert!(matches!(
+        zram_writeback
+            .mark_and_flush_pages::<MockOps>(&params, base_time + Duration::from_secs(99)),
+        Err(Error::BackoffTime)
+    ));
+}
+
+#[test]
+fn mark_and_flush_pages_after_backoff() {
+    let _m = OPS_MTX.lock();
+    let wmock = MockOps::write_context();
+    wmock.expect().returning(|_, _| Ok(()));
+    let rmock = MockOps::read_to_string_context();
+    setup_default_meminfo(&rmock);
+    let params = Params { backoff_duration: Duration::from_secs(100), ..Default::default() };
+    let base_time = Instant::now();
+    let mut zram_writeback = ZramWriteback::new();
+    assert!(zram_writeback.mark_and_flush_pages::<MockOps>(&params, base_time).is_ok());
+    wmock.checkpoint();
+    wmock.expect().withf(write_path_params(ZRAM_IDLE_PATH)).returning(|_, _| Ok(()));
+
+    wmock.expect().withf(write_path_params(ZRAM_WRITEBACK_PATH)).times(3).returning(|_, _| Ok(()));
+
+    assert!(zram_writeback
+        .mark_and_flush_pages::<MockOps>(&params, base_time + Duration::from_secs(100))
+        .is_ok());
+}
+
+#[test]
+fn mark_and_flush_pages_idle_time() {
+    let _m = OPS_MTX.lock();
+    let wmock = MockOps::write_context();
+    wmock.expect().withf(write_path_params(ZRAM_WRITEBACK_PATH)).returning(|_, _| Ok(()));
+    let rmock = MockOps::read_to_string_context();
+    let meminfo = "MemTotal: 10000 kB
+        MemAvailable: 8000 kB";
+    rmock.expect().withf(path_params(MEMINFO_PATH)).returning(|_| Ok(meminfo.to_string()));
+    let params = Params {
+        min_idle: Duration::from_secs(3600),
+        max_idle: Duration::from_secs(4000),
+        ..Default::default()
+    };
+    let mut zram_writeback = ZramWriteback::new();
+
+    wmock.expect().withf(write_params(ZRAM_IDLE_PATH, "3747")).times(2).returning(|_, _| Ok(()));
+
+    assert!(zram_writeback.mark_and_flush_pages::<MockOps>(&params, Instant::now()).is_ok());
+}
+
+#[test]
+fn mark_and_flush_pages_calculate_idle_failure() {
+    let _m = OPS_MTX.lock();
+    let wmock = MockOps::write_context();
+    wmock.expect().withf(write_path_params(ZRAM_WRITEBACK_PATH)).returning(|_, _| Ok(()));
+    let params = Params {
+        min_idle: Duration::from_secs(4000),
+        max_idle: Duration::from_secs(3600),
+        ..Default::default()
+    };
+    let mut zram_writeback = ZramWriteback::new();
+
+    assert!(matches!(
+        zram_writeback.mark_and_flush_pages::<MockOps>(&params, Instant::now()),
+        Err(Error::CalculateIdle(_))
+    ));
+}
+
+#[test]
+fn mark_and_flush_pages_mark_idle_failure() {
+    let _m = OPS_MTX.lock();
+    let wmock = MockOps::write_context();
+    wmock.expect().withf(write_path_params(ZRAM_WRITEBACK_PATH)).returning(|_, _| Ok(()));
+    let rmock = MockOps::read_to_string_context();
+    setup_default_meminfo(&rmock);
+    let params = Params::default();
+    let mut zram_writeback = ZramWriteback::new();
+
+    wmock
+        .expect()
+        .withf(write_path_params(ZRAM_IDLE_PATH))
+        .returning(|_, _| Err(std::io::Error::other("error")));
+
+    assert!(matches!(
+        zram_writeback.mark_and_flush_pages::<MockOps>(&params, Instant::now()),
+        Err(Error::MarkIdle(_))
+    ));
+}
+
+#[test]
+fn mark_and_flush_pages_skip_huge_idle() {
+    let _m = OPS_MTX.lock();
+    let wmock = MockOps::write_context();
+    wmock.expect().withf(write_path_params(ZRAM_IDLE_PATH)).returning(|_, _| Ok(()));
+    let rmock = MockOps::read_to_string_context();
+    setup_default_meminfo(&rmock);
+    let params = Params { huge_idle: false, ..Default::default() };
+    let mut zram_writeback = ZramWriteback::new();
+
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "huge_idle"))
+        .times(0)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "idle"))
+        .times(1)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "huge"))
+        .times(1)
+        .returning(|_, _| Ok(()));
+
+    assert!(zram_writeback.mark_and_flush_pages::<MockOps>(&params, Instant::now()).is_ok());
+}
+
+#[test]
+fn mark_and_flush_pages_skip_idle() {
+    let _m = OPS_MTX.lock();
+    let wmock = MockOps::write_context();
+    wmock.expect().withf(write_path_params(ZRAM_IDLE_PATH)).returning(|_, _| Ok(()));
+    let rmock = MockOps::read_to_string_context();
+    setup_default_meminfo(&rmock);
+    let params = Params { idle: false, ..Default::default() };
+    let mut zram_writeback = ZramWriteback::new();
+
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "huge_idle"))
+        .times(1)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "idle"))
+        .times(0)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "huge"))
+        .times(1)
+        .returning(|_, _| Ok(()));
+
+    assert!(zram_writeback.mark_and_flush_pages::<MockOps>(&params, Instant::now()).is_ok());
+}
+
+#[test]
+fn mark_and_flush_pages_skip_huge() {
+    let _m = OPS_MTX.lock();
+    let wmock = MockOps::write_context();
+    wmock.expect().withf(write_path_params(ZRAM_IDLE_PATH)).returning(|_, _| Ok(()));
+    let rmock = MockOps::read_to_string_context();
+    setup_default_meminfo(&rmock);
+    let params = Params { huge: false, ..Default::default() };
+    let mut zram_writeback = ZramWriteback::new();
+
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "huge_idle"))
+        .times(1)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "idle"))
+        .times(1)
+        .returning(|_, _| Ok(()));
+    wmock
+        .expect()
+        .withf(write_params(ZRAM_WRITEBACK_PATH, "huge"))
+        .times(0)
+        .returning(|_, _| Ok(()));
+
+    assert!(zram_writeback.mark_and_flush_pages::<MockOps>(&params, Instant::now()).is_ok());
+}