///|
priv struct FileFrameWire {
elapsed_ms : String
boot_id : String
pid_namespace : String
captured_at : String
file_key : String
file_error : String
locks : String
locks_error : String
} derive(@json.FromJson, ToJson)
///|
priv struct FileCaptureWire {
schema : String
frames : Array[FileFrameWire]
} derive(@json.FromJson, ToJson)
///|
pub struct FileCapture {
priv frames : Array[FileFrameWire]
priv mut stored_chars : Int32
}
///|
pub(all) struct FileFrameReport {
elapsed_ms : Int64
path_state : String
waiting_records : Int32
entries : Array[Entry]
issues : Array[String]
} derive(ToJson)
///|
pub(all) struct FileReport {
file : FileKey
sample_count : Int32
observed_span_ms : Int64
peak_waiting_records : Int32
max_consecutive_wait_samples : Int32
frames : Array[FileFrameReport]
} derive(ToJson)
///|
pub extend FileFrameReport with ToJson::{to_json}
///|
pub extend FileReport with ToJson::{to_json}
///|
pub fn file_capture() -> FileCapture {
{ frames: [], stored_chars: 0, }
}
///|
pub fn FileCapture::record(
self : FileCapture,
raw : String,
) -> Unit raise LockError {
let frame : FileFrameWire = @json.from_json(task_json(raw)) catch {
_ => {
invalid(0, "invalid file capture frame")
abort("unreachable")
}
}
self.add_file_frame(frame)
}
///|
fn FileCapture::add_file_frame(
self : FileCapture,
frame : FileFrameWire,
) -> Unit raise LockError {
if self.frames.length() >= 60 {
invalid(0, "file capture exceeds 60 samples")
}
if frame.boot_id.is_empty() ||
frame.boot_id.length() > 128 ||
frame.pid_namespace.is_empty() ||
frame.pid_namespace.length() > 128 ||
frame.captured_at.length() > 64 ||
frame.file_error.length() > 256 ||
frame.locks_error.length() > 256 {
invalid(0, "invalid file capture scope or metadata")
}
let elapsed = nonnegative(frame.elapsed_ms, 0)
if self.frames.last() is Some(previous) {
if elapsed <= nonnegative(previous.elapsed_ms, 0) {
invalid(0, "sample elapsed times must strictly increase")
}
if frame.boot_id != previous.boot_id ||
frame.pid_namespace != previous.pid_namespace {
invalid(0, "boot/procfs scope changed within file capture")
}
}
let current = if frame.file_key == "" {
None
} else {
Some(file_key(frame.file_key))
}
if current is None && frame.file_error == "" {
invalid(0, "missing file identity without read status")
}
if self.frames.is_empty() && (current is None || frame.file_error != "") {
invalid(0, "file must be observable in the first sample")
}
let key = match self.frames.get(0) {
Some(first) => file_key(first.file_key)
None => current.unwrap()
}
let snapshot = if frame.locks_error == "" {
parse(frame.locks)
} else {
parse("")
}
let matched = snapshot.matching(file=key)
let saved = {
..frame,
locks: snapshot
.with_blockers(matched.map(e => e.line))
.map(proc_line)
.join("\n"),
}
let size = Json(saved).stringify().length()
if self.stored_chars > 16000000 - size {
invalid(0, "file capture storage budget exceeded")
}
self.frames.push(saved)
self.stored_chars += size
}
///|
pub fn FileCapture::to_json(self : FileCapture) -> Json {
Json(FileCaptureWire::{ schema: "moonlocklens-file/1", frames: self.frames, })
}
///|
pub fn parse_file_capture(text : String) -> FileCapture raise LockError {
let wire : FileCaptureWire = @json.from_json(task_json(text)) catch {
_ => {
invalid(0, "invalid file capture schema")
abort("unreachable")
}
}
if wire.schema != "moonlocklens-file/1" || wire.frames.is_empty() {
invalid(0, "unsupported or empty file capture")
}
let capture = file_capture()
for frame in wire.frames {
capture.add_file_frame(frame)
}
capture
}
///|
pub fn FileCapture::report(self : FileCapture) -> FileReport raise LockError {
guard self.frames.get(0) is Some(first) else {
invalid(0, "cannot report an empty file capture")
abort("unreachable")
}
let key = file_key(first.file_key)
let mut peak = 0
let mut streak = 0
let mut max_streak = 0
let frames = self.frames.map(frame => {
let entries = parse(frame.locks).entries()
let waiting = entries
.filter(e => e.file == Some(key) && e.depth > 0)
.length()
peak = Int32::max(peak, waiting)
streak = if waiting > 0 { streak + 1 } else { 0 }
max_streak = Int32::max(max_streak, streak)
let path_state = if frame.file_key != "" && file_key(frame.file_key) != key {
"identity_changed"
} else if frame.file_error != "" {
"unavailable"
} else {
"present"
}
let issues : Array[String] = []
if path_state == "identity_changed" {
issues.push(
"Path now names a different device/inode; this report continues to track the original file identity.",
)
}
if frame.file_error != "" {
issues.push("Path read status: " + frame.file_error)
}
if frame.locks_error != "" {
issues.push("Lock evidence unavailable: " + frame.locks_error)
}
FileFrameReport::{
elapsed_ms: nonnegative(frame.elapsed_ms, 0),
path_state,
waiting_records: waiting,
entries,
issues,
}
})
{
file: key,
sample_count: frames.length(),
observed_span_ms: frames.last().unwrap().elapsed_ms - frames[0].elapsed_ms,
peak_waiting_records: peak,
max_consecutive_wait_samples: max_streak,
frames,
}
}
///|
pub fn FileReport::to_markdown(self : FileReport) -> String {
let mut out = "# File contention observations\n\nFile \{self.file.major.to_string(radix=16)}:\{self.file.minor.to_string(radix=16)}:\{self.file.inode}. Samples: \{self.sample_count}; observed span: \{self.observed_span_ms} ms. Peak waiting records: \{self.peak_waiting_records}; consecutive wait samples: \{self.max_consecutive_wait_samples}.\n\n"
for frame in self.frames {
out += "## Sample +\{frame.elapsed_ms} ms - \{frame.path_state}\n\n"
for e in frame.entries {
let parent = match e.parent_line {
Some(line) => "; waits for L\{line}"
None => "; root record"
}
out += "- L\{e.line}: \{md(proc_line(e))}\{parent}\n"
}
if frame.entries.is_empty() {
out += "No lock records observed for the original file.\n"
}
for issue in frame.issues {
out += "\nEvidence gap: \{md(issue)}\n"
}
out += "\n"
}
out +
"OFD owner -1 remains unknown; records are not unique process counts. Sampling gaps and absence of lock records do not prove application completion.\n"
}