fix: Sentry production hardening (release/OS tags, unhandled-rejection & render-loop guards) (#4929)
This commit is contained in:
@@ -12,10 +12,52 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
propagate_sentry_dsn();
|
propagate_sentry_dsn();
|
||||||
|
propagate_app_version();
|
||||||
|
|
||||||
tauri_build::build()
|
tauri_build::build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bake the app version from `package.json` into the crate as `READEST_APP_VERSION`
|
||||||
|
/// (read back via `option_env!`). Sentry keys its release/environment off this
|
||||||
|
/// rather than `CARGO_PKG_VERSION`, because the crate version in `Cargo.toml` is
|
||||||
|
/// not kept in sync with the app version (and only `package.json` carries the
|
||||||
|
/// nightly `-YYYYMMDDHH` stamp). Absent/unparseable => unset, so the Rust code
|
||||||
|
/// falls back to the crate version.
|
||||||
|
fn propagate_app_version() {
|
||||||
|
let package_json = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap())
|
||||||
|
.join("..")
|
||||||
|
.join("package.json");
|
||||||
|
println!("cargo:rerun-if-changed={}", package_json.display());
|
||||||
|
|
||||||
|
if let Some(version) = read_json_string_field(&package_json, "version") {
|
||||||
|
println!("cargo:rustc-env=READEST_APP_VERSION={version}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a top-level `"key": "value"` string from a JSON file without pulling in a
|
||||||
|
/// JSON parser. Returns the first match; `None` if the file/key is absent or the
|
||||||
|
/// value is empty. `package.json`'s own `"version"` is the first `"version"` key.
|
||||||
|
fn read_json_string_field(path: &Path, key: &str) -> Option<String> {
|
||||||
|
let contents = fs::read_to_string(path).ok()?;
|
||||||
|
let needle = format!("\"{key}\"");
|
||||||
|
for line in contents.lines() {
|
||||||
|
let Some(rest) = line.trim_start().strip_prefix(&needle) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = rest
|
||||||
|
.trim_start()
|
||||||
|
.strip_prefix(':')?
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(',')
|
||||||
|
.trim()
|
||||||
|
.trim_matches('"');
|
||||||
|
if !value.is_empty() {
|
||||||
|
return Some(value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Bake the Sentry DSN into the crate at build time via `cargo:rustc-env`, so
|
/// Bake the Sentry DSN into the crate at build time via `cargo:rustc-env`, so
|
||||||
/// `option_env!("SENTRY_DSN")` (and, on iOS, the `readest_sentry_dsn` FFI) sees
|
/// `option_env!("SENTRY_DSN")` (and, on iOS, the `readest_sentry_dsn` FFI) sees
|
||||||
/// it. Precedence: an existing `SENTRY_DSN` in the environment (CI secret / shell
|
/// it. Precedence: an existing `SENTRY_DSN` in the environment (CI secret / shell
|
||||||
|
|||||||
@@ -313,10 +313,31 @@ pub fn run() {
|
|||||||
sentry::init((
|
sentry::init((
|
||||||
dsn,
|
dsn,
|
||||||
sentry::ClientOptions {
|
sentry::ClientOptions {
|
||||||
release: sentry::release_name!(),
|
release: Some(sentry_config::sentry_release().into()),
|
||||||
environment: Some(sentry_config::sentry_environment().into()),
|
environment: Some(sentry_config::sentry_environment().into()),
|
||||||
traces_sample_rate: 0.0,
|
traces_sample_rate: 0.0,
|
||||||
send_default_pii: false,
|
send_default_pii: false,
|
||||||
|
// On Android the context integration reads `uname()` and reports
|
||||||
|
// the OS as "Linux"; relabel it "Android" (and recover the Android
|
||||||
|
// version from the kernel string) so events group correctly.
|
||||||
|
before_send: Some(std::sync::Arc::new(|mut event| {
|
||||||
|
if let Some(sentry::protocol::Context::Os(os)) = event.contexts.get_mut("os") {
|
||||||
|
if let Some(name) = sentry_config::corrected_os_name(
|
||||||
|
std::env::consts::OS,
|
||||||
|
os.name.as_deref(),
|
||||||
|
) {
|
||||||
|
os.name = Some(name.to_owned());
|
||||||
|
if let Some(version) = os
|
||||||
|
.version
|
||||||
|
.as_deref()
|
||||||
|
.and_then(sentry_config::android_version_from_uname)
|
||||||
|
{
|
||||||
|
os.version = Some(version);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(event)
|
||||||
|
})),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -27,9 +27,53 @@ pub fn environment_for_version(version: &str) -> &'static str {
|
|||||||
"production"
|
"production"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The user-facing application version, taken from `package.json` at build time
|
||||||
|
/// (baked as `READEST_APP_VERSION` by `build.rs`). Falls back to the crate version
|
||||||
|
/// only if the bake is missing. The crate version (`Cargo.toml`, e.g. `0.2.2`) is
|
||||||
|
/// not kept in lockstep with the app, so Sentry must key its release and
|
||||||
|
/// environment off the app version instead of `CARGO_PKG_VERSION`.
|
||||||
|
pub fn app_version() -> &'static str {
|
||||||
|
option_env!("READEST_APP_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins a crate name and version into Sentry's `name@version` release format
|
||||||
|
/// (e.g. `Readest@0.11.17`), matching `sentry::release_name!()`'s shape.
|
||||||
|
pub fn release_name(name: &str, version: &str) -> String {
|
||||||
|
format!("{name}@{version}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sentry `release` for the current build, keyed off the app version.
|
||||||
|
pub fn sentry_release() -> String {
|
||||||
|
release_name(env!("CARGO_PKG_NAME"), app_version())
|
||||||
|
}
|
||||||
|
|
||||||
/// Sentry `environment` for the current build.
|
/// Sentry `environment` for the current build.
|
||||||
pub fn sentry_environment() -> &'static str {
|
pub fn sentry_environment() -> &'static str {
|
||||||
environment_for_version(env!("CARGO_PKG_VERSION"))
|
environment_for_version(app_version())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Rust SDK's context integration derives the OS name from `uname()`, which
|
||||||
|
/// reports "Linux" on Android (Android runs a Linux kernel). Given the build's
|
||||||
|
/// `target_os` and the already-detected name, return the name Sentry should show,
|
||||||
|
/// or `None` to leave it unchanged. Only Android is remapped; the native
|
||||||
|
/// sentry-android/-cocoa SDKs report other platforms correctly on their own.
|
||||||
|
pub fn corrected_os_name(target_os: &str, detected: Option<&str>) -> Option<&'static str> {
|
||||||
|
(target_os == "android" && detected != Some("Android")).then_some("Android")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extracts the Android platform version from `uname`'s kernel release string
|
||||||
|
/// (e.g. `6.1.162-android14-11-g5e8b0cffebd1-ab15202165` -> `"14"`). `uname`
|
||||||
|
/// reports the Linux kernel version, not the Android version, but Android kernels
|
||||||
|
/// embed an `android<N>` token. Returns `None` when no such token is present.
|
||||||
|
pub fn android_version_from_uname(release: &str) -> Option<String> {
|
||||||
|
let digits = release
|
||||||
|
.split(['-', '_'])
|
||||||
|
.find_map(|token| token.strip_prefix("android"))?;
|
||||||
|
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
Some(digits.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// C-ABI accessor for the compile-time Sentry DSN, used by the iOS native
|
/// C-ABI accessor for the compile-time Sentry DSN, used by the iOS native
|
||||||
@@ -51,7 +95,10 @@ pub extern "C" fn readest_sentry_dsn() -> *const std::os::raw::c_char {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{dsn_from_env, environment_for_version};
|
use super::{
|
||||||
|
android_version_from_uname, corrected_os_name, dsn_from_env, environment_for_version,
|
||||||
|
release_name,
|
||||||
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dsn_is_none_when_unset_or_blank() {
|
fn dsn_is_none_when_unset_or_blank() {
|
||||||
@@ -79,4 +126,43 @@ mod tests {
|
|||||||
assert_eq!(environment_for_version("0.11.17-rc.1"), "production");
|
assert_eq!(environment_for_version("0.11.17-rc.1"), "production");
|
||||||
assert_eq!(environment_for_version("1.0.0-2026"), "production");
|
assert_eq!(environment_for_version("1.0.0-2026"), "production");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_name_joins_crate_name_and_app_version() {
|
||||||
|
assert_eq!(release_name("Readest", "0.11.17"), "Readest@0.11.17");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn os_name_is_corrected_to_android_on_android_target() {
|
||||||
|
assert_eq!(corrected_os_name("android", Some("Linux")), Some("Android"));
|
||||||
|
// No detected name still yields Android on an Android build.
|
||||||
|
assert_eq!(corrected_os_name("android", None), Some("Android"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn os_name_is_left_unchanged_off_android_or_already_correct() {
|
||||||
|
assert_eq!(corrected_os_name("android", Some("Android")), None);
|
||||||
|
assert_eq!(corrected_os_name("linux", Some("Linux")), None);
|
||||||
|
assert_eq!(corrected_os_name("macos", Some("macOS")), None);
|
||||||
|
assert_eq!(corrected_os_name("windows", Some("Windows")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_version_parsed_from_kernel_release() {
|
||||||
|
assert_eq!(
|
||||||
|
android_version_from_uname("6.1.162-android14-11-g5e8b0cffebd1-ab15202165"),
|
||||||
|
Some("14".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
android_version_from_uname("5.10.101-android12-9-00001"),
|
||||||
|
Some("12".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn android_version_is_none_without_android_token() {
|
||||||
|
assert_eq!(android_version_from_uname("6.1.162"), None);
|
||||||
|
assert_eq!(android_version_from_uname(""), None);
|
||||||
|
assert_eq!(android_version_from_uname("6.1.162-androidX"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, test, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@/services/environment', () => ({
|
||||||
|
getAPIBaseUrl: () => 'https://api.test',
|
||||||
|
isWebAppPlatform: () => false,
|
||||||
|
}));
|
||||||
|
vi.mock('@/utils/access', () => ({ getUserID: vi.fn() }));
|
||||||
|
vi.mock('@/utils/fetch', () => ({ fetchWithAuth: vi.fn() }));
|
||||||
|
vi.mock('@/utils/transfer', () => ({
|
||||||
|
tauriUpload: vi.fn(),
|
||||||
|
tauriDownload: vi.fn(),
|
||||||
|
webUpload: vi.fn(),
|
||||||
|
webDownload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { deleteFile } from '@/libs/storage';
|
||||||
|
import { getUserID } from '@/utils/access';
|
||||||
|
import { fetchWithAuth } from '@/utils/fetch';
|
||||||
|
|
||||||
|
describe('deleteFile (cloud) — best-effort cleanup (READEST-5)', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
test('resolves without throwing when the delete request fails', async () => {
|
||||||
|
vi.mocked(getUserID).mockResolvedValue('user-1');
|
||||||
|
vi.mocked(fetchWithAuth).mockRejectedValue(new Error('network down'));
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Callers dispatch this without awaiting; a throw here becomes an unhandled
|
||||||
|
// rejection. It must swallow the failure and just log it.
|
||||||
|
await expect(deleteFile('books/x.epub')).resolves.toBeUndefined();
|
||||||
|
expect(warn).toHaveBeenCalled();
|
||||||
|
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves without throwing when the user is not authenticated', async () => {
|
||||||
|
vi.mocked(getUserID).mockResolvedValue(null);
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(deleteFile('books/x.epub')).resolves.toBeUndefined();
|
||||||
|
expect(fetchWithAuth).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
warn.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -76,6 +76,16 @@ describe('transferStore', () => {
|
|||||||
useTransferStore.getState().updateTransferProgress('nope', 10, 10, 100, 5);
|
useTransferStore.getState().updateTransferProgress('nope', 10, 10, 100, 5);
|
||||||
expect(useTransferStore.getState().transfers).toEqual(before);
|
expect(useTransferStore.getState().transfers).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('re-applying identical values keeps the same state reference (READEST-2)', () => {
|
||||||
|
const id = useTransferStore.getState().addTransfer('h', 'B', 'upload');
|
||||||
|
useTransferStore.getState().updateTransferProgress(id, 50, 500, 1000, 100);
|
||||||
|
const before = useTransferStore.getState().transfers;
|
||||||
|
// A repeated write with unchanged values must not allocate a new state,
|
||||||
|
// otherwise subscribers re-render and can drive an update loop.
|
||||||
|
useTransferStore.getState().updateTransferProgress(id, 50, 500, 1000, 100);
|
||||||
|
expect(useTransferStore.getState().transfers).toBe(before);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── setTransferStatus ────────────────────────────────────────────
|
// ── setTransferStatus ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import { isSyncCategoryEnabled } from '@/services/sync/syncCategories';
|
|||||||
|
|
||||||
const nowSec = () => Math.floor(Date.now() / 1000);
|
const nowSec = () => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
// Statistics are best-effort telemetry: a failed write or sync (e.g. the
|
||||||
|
// statistics DB torn down mid-flight on app teardown -> "database ... not
|
||||||
|
// loaded", Sentry READEST-6) must never surface as an unhandled rejection.
|
||||||
|
const runBestEffort = (work: Promise<unknown>): void => {
|
||||||
|
void work.catch((err) => console.warn('[stats] background operation failed:', err));
|
||||||
|
};
|
||||||
|
|
||||||
export default function ReadingStatsTracker({ bookKey }: { bookKey: string }) {
|
export default function ReadingStatsTracker({ bookKey }: { bookKey: string }) {
|
||||||
const { appService } = useEnv();
|
const { appService } = useEnv();
|
||||||
// Progress lives in readerProgressStore, not readerStore.viewStates.
|
// Progress lives in readerProgressStore, not readerStore.viewStates.
|
||||||
@@ -41,7 +48,7 @@ export default function ReadingStatsTracker({ bookKey }: { bookKey: string }) {
|
|||||||
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
||||||
pushTimerRef.current = setTimeout(() => {
|
pushTimerRef.current = setTimeout(() => {
|
||||||
const db = dbRef.current;
|
const db = dbRef.current;
|
||||||
if (db) void pushStats(db, new SyncClient());
|
if (db) runBestEffort(pushStats(db, new SyncClient()));
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,7 +58,7 @@ export default function ReadingStatsTracker({ bookKey }: { bookKey: string }) {
|
|||||||
StatisticsDb.open(appService).then((db) => {
|
StatisticsDb.open(appService).then((db) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
dbRef.current = db;
|
dbRef.current = db;
|
||||||
if (syncEnabled()) void pullStats(db, new SyncClient());
|
if (syncEnabled()) runBestEffort(pullStats(db, new SyncClient()));
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -60,15 +67,20 @@ export default function ReadingStatsTracker({ bookKey }: { bookKey: string }) {
|
|||||||
}, [appService]);
|
}, [appService]);
|
||||||
|
|
||||||
// Persist flushed events into the statistics DB.
|
// Persist flushed events into the statistics DB.
|
||||||
const persist = (events: FlushedEvent[]): Promise<void> => {
|
const persist = async (events: FlushedEvent[]): Promise<void> => {
|
||||||
const db = dbRef.current;
|
const db = dbRef.current;
|
||||||
if (!db || !bookMd5 || events.length === 0) return Promise.resolve();
|
if (!db || !bookMd5 || events.length === 0) return;
|
||||||
return (async () => {
|
try {
|
||||||
const idBook = await db.upsertBook({ bookMd5, title, authors });
|
const idBook = await db.upsertBook({ bookMd5, title, authors });
|
||||||
for (const e of events) await db.insertPageEvent(idBook, e);
|
for (const e of events) await db.insertPageEvent(idBook, e);
|
||||||
await db.recomputeBookTotals(idBook);
|
await db.recomputeBookTotals(idBook);
|
||||||
schedulePush();
|
schedulePush();
|
||||||
})();
|
} catch (err) {
|
||||||
|
// The statistics DB can be closed mid-write on app/tab teardown
|
||||||
|
// ("database ... not loaded", Sentry READEST-6). Best-effort: log and
|
||||||
|
// never reject, so the fire-and-forget dispatch sites stay safe.
|
||||||
|
console.warn('[stats] failed to persist reading events:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const armIdle = () => {
|
const armIdle = () => {
|
||||||
@@ -108,11 +120,12 @@ export default function ReadingStatsTracker({ bookKey }: { bookKey: string }) {
|
|||||||
return () => {
|
return () => {
|
||||||
if (idleRef.current) clearTimeout(idleRef.current);
|
if (idleRef.current) clearTimeout(idleRef.current);
|
||||||
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
if (pushTimerRef.current) clearTimeout(pushTimerRef.current);
|
||||||
const closePersist = persist(coreRef.current.onClose(nowSec()));
|
runBestEffort(
|
||||||
void closePersist.then(() => {
|
persist(coreRef.current.onClose(nowSec())).then(() => {
|
||||||
if (syncEnabled() && dbRef.current) return pushStats(dbRef.current, new SyncClient());
|
if (syncEnabled() && dbRef.current) return pushStats(dbRef.current, new SyncClient());
|
||||||
return undefined;
|
return undefined;
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [bookMd5]);
|
}, [bookMd5]);
|
||||||
|
|||||||
@@ -230,8 +230,10 @@ export const deleteFile = async (filePath: string) => {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('File deletion failed:', error);
|
// Best-effort cloud cleanup: removing the remote copy is non-critical and
|
||||||
throw new Error('File deletion failed');
|
// callers dispatch this without awaiting, so throwing here surfaces as an
|
||||||
|
// unhandled promise rejection (Sentry READEST-5). Log and swallow instead.
|
||||||
|
console.warn('File deletion failed:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,18 @@ export const useTransferStore = create<TransferState>((set, get) => ({
|
|||||||
const transfer = state.transfers[transferId];
|
const transfer = state.transfers[transferId];
|
||||||
if (!transfer) return state;
|
if (!transfer) return state;
|
||||||
|
|
||||||
|
// No-op when nothing changed: re-applying identical progress would
|
||||||
|
// otherwise allocate a new state on every call and re-render every
|
||||||
|
// subscriber, which can sustain a render/update loop (Sentry READEST-2).
|
||||||
|
if (
|
||||||
|
transfer.progress === progress &&
|
||||||
|
transfer.transferredBytes === transferred &&
|
||||||
|
transfer.totalBytes === total &&
|
||||||
|
transfer.transferSpeed === speed
|
||||||
|
) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transfers: {
|
transfers: {
|
||||||
...state.transfers,
|
...state.transfers,
|
||||||
|
|||||||
Reference in New Issue
Block a user