sonar

Scan files at memory bandwidth speed.
Log | Files | Refs

commit e247b0cba247a06b478e752df7ca44a0c07b681e
parent 60efb2ca4d52b4907e976ebe1c948315dcf570bd
Author: Jack Mordaunt <jackmordaunt.dev@gmail.com>
Date:   Sun, 20 Sep 2026 15:54:24 -0300

main: run a reader from one place

Driving a reader was written twice, once for the batch path and once for the live
one, which left a file about drawing frames holding an MFT read loop. The two
agreed, but had already drifted on a point worth not drifting on: the batch path
named the root after folding rather than inside the projection, which is what once
let a watcher catch the table's ".".

fill.odin owns it now. Whether the tree is folded as it fills or once at the end
is the only difference left between a watched scan and an unwatched one, and
live.odin imports scan and nothing else.

Diffstat:
Afill.odin | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlive.odin | 159++++++++-----------------------------------------------------------------------
Mmain.odin | 69+++++++++++++++++++--------------------------------------------------
3 files changed, 195 insertions(+), 194 deletions(-)

diff --git a/fill.odin b/fill.odin @@ -0,0 +1,161 @@ +package main + +import "core:fmt" +import "core:sync" +import "core:thread" + +import "ntfs" +import "scan" +import "walk" + +/* +Runs a reader into the tree. + +The one place that knows how each reader is driven. A watched scan and an unwatched +one reach the same tree by the same route, rather than by two routes that happen to +agree; the only difference between them is whether the tree is folded as it fills or +once at the end. + +`scanning` is clear once nothing is filling the tree any more. A caller that draws +while it runs watches that; one that does not simply waits for `fill_run` to return. +*/ +Fill :: struct { + // What to read, and how. + target: scan.Target, + engine: scan.Engine, + opts: ntfs.Read_Options, + wcfg: walk.Config, + // Whether anything is reading the tree while it fills. A reader that has to fold + // pays for doing so repeatedly, which is worth it only if someone is looking. + watched: bool, + tree: ^scan.Tree, + // The MFT reader's own table, which carries what only NTFS knows. It outlives the + // tree, whose names are borrowed from it. + table: ntfs.Mft, + // What it left behind. + mft_err: ntfs.Error, + walk_err: walk.Error, + tree_err: scan.Error, + reading: b32, // the table is still being read + scanning: b32, // the tree is still being filled +} + +fill_destroy :: proc(f: ^Fill) { + ntfs.mft_destroy(&f.table) +} + +// Run the reader to completion. The one place a reader is chosen. +fill_run :: proc(f: ^Fill) { + defer sync.atomic_store(&f.scanning, false) + + switch f.engine { + case .Mft: + fill_mft(f) + case .Walk: + fill_walk(f) + case .None: + } +} + +// Say what went wrong, if anything did. The MFT's failures get their own words +// because they are the ones a reader of them can act on. +fill_report :: proc(f: ^Fill) -> bool { + if f.mft_err != nil { + #partial switch f.mft_err { + case .Access_Denied: + fmt.eprintln( + "error: access denied. Reading a raw volume needs an administrator prompt.", + ) + case .Not_Ntfs: + fmt.eprintln("error: not an NTFS volume") + case .Open_Failed: + fmt.eprintfln("error: could not open %s", f.target.volume) + case: + fmt.eprintfln("error: %v", f.mft_err) + } + return true + } + if f.walk_err != nil { + fmt.eprintfln( + "error: walking %s: %v", + scan.location(f.target, context.temp_allocator), + f.walk_err, + ) + return true + } + if f.tree_err != nil { + fmt.eprintfln("error: %v", f.tree_err) + return true + } + return false +} + +/* +Read the table, folding it into the tree. + +read_mft blocks until the whole table is read, so a watched scan gives it a thread and +folds beside it. Either way a record settled part way through misses whatever +extension records added to it afterwards, so every record is folded again at the end +and the tree restated, which is how a watcher learns its totals have to start over. + +The root is named inside the projection rather than afterwards: the table calls it +".", and a watcher has been known to catch the dot. +*/ +@(private = "file") +fill_mft :: proc(f: ^Fill) { + p: ntfs.Projection + p.root_name = scan.top(f.target) + defer ntfs.projection_destroy(&p) + + if f.watched { + sync.atomic_store(&f.reading, true) + reader := thread.create_and_start_with_poly_data(f, fill_mft_read) + if reader == nil { + f.mft_err = .Open_Failed + return + } + defer thread.destroy(reader) + + for { + // Read first, so a table that finishes mid-fold still gets one more pass. + reading := bool(sync.atomic_load(&f.reading)) + if ntfs.mft_ready(&f.table) { + if err := ntfs.to_tree(&f.table, f.tree, &p); err != nil { + f.tree_err = err + break + } + } + if !reading { + break + } + } + thread.join(reader) + } else { + f.mft_err = ntfs.read_mft(f.target.volume, &f.table, f.opts) + } + if f.mft_err != nil || f.tree_err != nil { + return + } + + ntfs.projection_reset(&p) + if err := ntfs.to_tree(&f.table, f.tree, &p); err != nil { + f.tree_err = err + return + } + scan.restate(f.tree) +} + +@(private = "file") +fill_mft_read :: proc(f: ^Fill) { + f.mft_err = ntfs.read_mft(f.target.volume, &f.table, f.opts) + sync.atomic_store(&f.reading, false) +} + +// Walk directories straight into the tree. Nothing is restated: a walk reaches a child +// through its parent, so a node is final when it is written, watched or not. +@(private = "file") +fill_walk :: proc(f: ^Fill) { + root := scan.location(f.target, context.allocator) + defer delete(root) + f.walk_err = walk.scan(root, f.tree, f.wcfg) +} diff --git a/live.odin b/live.odin @@ -5,9 +5,7 @@ import "core:sync" import "core:thread" import "core:time" -import "ntfs" import "scan" -import "walk" /* Stand in for a UI. @@ -17,89 +15,30 @@ everything derived from it, and this loop drawing. The point of the arrangement that a frame costs the same whatever the volume holds, because it reads a published snapshot of twenty rows rather than the tree. -Which reader fills it is decided in one switch below and nowhere else. The builder is -told nothing about it: a reader settles nodes, and restates if it has to correct -itself, and that is the whole of the conversation. +Which reader fills it is not decided here, and nothing here knows which one did. A +reader settles nodes, and restates if it has to correct itself; that is the whole of +the conversation. Nothing here waits on the builder. If a build pass is mid-write the loop draws the answer from the last one. */ FRAME :: 16 * time.Millisecond -// A reader filling the tree, and what it left behind. Only the fields its own engine -// uses are set. -@(private = "file") -Fill :: struct { - tree: ^scan.Tree, - table: ^ntfs.Mft, - volume: string, - // Where a walk starts, or what an MFT calls its root. One reader runs, and it - // reads the one that means something to it. - root: string, - opts: ntfs.Read_Options, - wcfg: walk.Config, - mft_err: ntfs.Error, - walk_err: walk.Error, - tree_err: scan.Error, - reading: b32, // the table is still being read - scanning: b32, // the tree is still being filled -} - -run_live :: proc( - target: scan.Target, - choice: scan.Choice, - opts: ntfs.Read_Options, - wcfg: walk.Config, -) -> int { - m: ntfs.Mft - defer ntfs.mft_destroy(&m) - - t: scan.Tree - if err := scan.tree_init(&t, 8); err != nil { - fmt.eprintfln("error: %v", err) - return 1 - } - defer scan.tree_destroy(&t) - +run_live :: proc(f: ^Fill, t: ^scan.Tree) -> int { out: scan.Publisher started := time.tick_now() - f := Fill { - tree = &t, - table = &m, - volume = target.volume, - opts = opts, - wcfg = wcfg, - scanning = true, - } + f.watched = true + sync.atomic_store(&f.scanning, true) + b := Builder { - tree = &t, + tree = t, out = &out, scanning = &f.scanning, started = started, } - // Freed at function scope. A defer inside the switch below would run as the case - // ended, with the reader still holding the string. - owned_root: string - defer if owned_root != "" { - delete(owned_root) - } - - // The one place a reader is chosen for a live scan, as in the batch path. - filler: ^thread.Thread - switch choice.engine { - case .Mft: - f.root = scan.top(target) - filler = thread.create_and_start_with_poly_data(&f, mft_fill) - case .Walk: - owned_root = scan.location(target, context.allocator) - f.root = owned_root - filler = thread.create_and_start_with_poly_data(&f, walk_fill) - case .None: - fmt.eprintln("error: nothing here can be scanned") - return 1 - } + filler := thread.create_and_start_with_poly_data(f, fill_thread) if filler == nil { fmt.eprintln("error: could not start the reader") return 1 @@ -138,7 +77,7 @@ run_live :: proc( last.nodes, last.pending, human(last.rows[0].bytes), - scan.path(&t, last.rows[0].node, context.temp_allocator), + scan.path(t, last.rows[0].node, context.temp_allocator), time.duration_milliseconds(time.tick_since(frame_start)), last.complete ? " (final)" : "", ) @@ -158,16 +97,7 @@ run_live :: proc( thread.join(filler) thread.join(builder) - if f.mft_err != nil { - fmt.eprintfln("error: %v", f.mft_err) - return 1 - } - if f.walk_err != nil { - fmt.eprintfln("error: %v", f.walk_err) - return 1 - } - if f.tree_err != nil { - fmt.eprintfln("error: %v", f.tree_err) + if fill_report(f) { return 1 } @@ -180,74 +110,15 @@ run_live :: proc( missed, ) - print_largest(&t, b.roll.totals[:], .files) - print_largest(&t, b.roll.totals[:], .directories) + print_largest(t, b.roll.totals[:], .files) + print_largest(t, b.roll.totals[:], .directories) return 0 } -/* -Read the table on its own threads, folding it into the tree as it fills. - -read_mft blocks until the whole table is read, so it gets a thread and this one folds -beside it. A record settled part way through misses whatever extension records added -to it afterwards, so once the reading is over every record is folded again and the -tree restated, which is how the builder learns its totals have to start over. -*/ -@(private = "file") -mft_fill :: proc(f: ^Fill) { - defer sync.atomic_store(&f.scanning, false) - - sync.atomic_store(&f.reading, true) - reader := thread.create_and_start_with_poly_data(f, mft_read) - if reader == nil { - f.mft_err = .Open_Failed - return - } - defer thread.destroy(reader) - - p: ntfs.Projection - p.root_name = f.root - defer ntfs.projection_destroy(&p) - - for { - // Read first, so a table that finishes mid-fold still gets one more pass. - reading := bool(sync.atomic_load(&f.reading)) - if ntfs.mft_ready(f.table) { - if err := ntfs.to_tree(f.table, f.tree, &p); err != nil { - f.tree_err = err - break - } - } - if !reading { - break - } - } - thread.join(reader) - if f.mft_err != nil || f.tree_err != nil { - return - } - - ntfs.projection_reset(&p) - if err := ntfs.to_tree(f.table, f.tree, &p); err != nil { - f.tree_err = err - return - } - scan.restate(f.tree) -} - -@(private = "file") -mft_read :: proc(f: ^Fill) { - f.mft_err = ntfs.read_mft(f.volume, f.table, f.opts) - sync.atomic_store(&f.reading, false) -} - -// Walk directories straight into the tree. Nothing is restated: a walk reaches a child -// through its parent, so a node is final when it is written. @(private = "file") -walk_fill :: proc(f: ^Fill) { - defer sync.atomic_store(&f.scanning, false) - f.walk_err = walk.scan(f.root, f.tree, f.wcfg) +fill_thread :: proc(f: ^Fill) { + fill_run(f) } @(private = "file") diff --git a/main.odin b/main.odin @@ -116,10 +116,6 @@ run :: proc() -> int { return 1 } - if opt.live { - return run_live(target, engine, opts, wcfg) - } - t: scan.Tree if err := scan.tree_init(&t, 8); err != nil { fmt.eprintfln("error: %v", err) @@ -127,56 +123,29 @@ run :: proc() -> int { } defer scan.tree_destroy(&t) - start := time.tick_now() - // The one place a reader is chosen. Each fills the same tree; the MFT reader also - // keeps its own table, which carries what only NTFS knows. - m: ntfs.Mft - have_mft := false - // Registered before the read, not after the switch: projecting the table can fail, - // and a defer below that point never covers the return it takes. - defer if have_mft { - ntfs.mft_destroy(&m) + // The table outlives the tree, whose names are borrowed from it. + f := Fill { + target = target, + engine = engine.engine, + opts = opts, + wcfg = wcfg, + tree = &t, } - switch engine.engine { - case .Mft: - err := ntfs.read_mft(target.volume, &m, opts) - if err != nil { - #partial switch err { - case .Access_Denied: - fmt.eprintln( - "error: access denied. Reading a raw volume needs an administrator prompt.", - ) - case .Not_Ntfs: - fmt.eprintln("error: not an NTFS volume") - case .Open_Failed: - fmt.eprintfln("error: could not open %s", target.volume) - case: - fmt.eprintfln("error: %v", err) - } - return 1 - } - have_mft = true - projection: ntfs.Projection - defer ntfs.projection_destroy(&projection) - if err := ntfs.to_tree(&m, &t, &projection); err != nil { - fmt.eprintfln("error: %v", err) - return 1 - } - // The table calls its root ".", so paths would lose the volume it came from. - rw := scan.writer(&t, 0) - scan.node(&t, t.root).name = scan.intern(&rw, scan.top(target)) - case .Walk: - root := scan.location(target, context.temp_allocator) - if err := walk.scan(root, &t, wcfg); err != nil { - fmt.eprintfln("error: walking %s: %v", root, err) - return 1 - } - case .None: + defer fill_destroy(&f) + + if opt.live { + return run_live(&f, &t) + } + + start := time.tick_now() + fill_run(&f) + if fill_report(&f) { + return 1 } elapsed := time.tick_since(start) - if have_mft { - print_native(&m, elapsed) + if engine.engine == .Mft { + print_native(&f.table, elapsed) } fmt.println() fmt.printfln(