///|
/// The command line front end.
///
/// The interface is deliberately boring: one subcommand per experiment, text in,
/// text out, and every number printed comes from the same library the tests
/// exercise. `run` and `compare` grow out of this skeleton as the TCP layer
/// lands; today `demo` already walks the whole stack, from a scenario to a link
/// report.
fn main {
let args = @env.args()
let command = if args.length() > 1 { args[1] } else { "help" }
match command {
"version" => println(version_line())
"run" => run_scenario(args)
"plot" => plot_trace(args)
"compare" => compare_scenarios(args)
"sweep" => sweep_scenarios(args)
"list" => list_scenarios()
"help" | "-h" | "--help" => usage()
_ => {
println("unknown command: " + command)
println("")
usage()
}
}
}
///|
fn version_line() -> String {
"moonnet-lab 0.1.0 (deterministic packet-level network simulator)"
}
///|
fn usage() -> Unit {
println(version_line())
println("")
println("usage: moonnet ")
println("")
println("commands:")
println(
" run FILE run a scenario file; --trace adds TCP state and event history",
)
println(" plot FILE render a single-flow trace JSON document as SVG")
println(
" compare FILE --cc reno,cubic run one scenario under several algorithms",
)
println(
" sweep FILE --field loss|delay_ms|bandwidth_mbps|queue_packets|seed --from A --to B --steps N --cc reno,cubic",
)
println(" list list the scenario files in ./scenarios")
println(" version print the version")
println(" help print this message")
}
///|
/// Run one scenario under several algorithms.
fn compare_scenarios(args : Array[String]) -> Unit {
let options = match
parse_common(args, "compare --cc reno,cubic") {
Ok(value) => value
Err(_) => return
}
let scenario = match options.scenario {
Some(value) => value
None => return
}
let results = @lab.compare(scenario, options.algorithms, seeds=options.seeds)
if options.as_json {
println(@lab.compare_json(scenario, results, seeds=options.seeds))
} else {
println(@lab.compare_lines(scenario, results, seeds=options.seeds))
}
}
///|
/// Run a scenario once per value of one parameter.
///
/// moonnet sweep scenarios/long-fat.json --field loss --from 0 --to 0.02 --steps 5 --cc reno,cubic
fn sweep_scenarios(args : Array[String]) -> Unit {
let options = match
parse_common(
args, "sweep --field loss --from 0 --to 0.02 --steps 5 --cc reno,cubic",
) {
Ok(value) => value
Err(_) => return
}
let scenario = match options.scenario {
Some(value) => value
None => return
}
let field = match @lab.SweepField::parse(options.field) {
Ok(value) => value
Err(message) => {
println(message)
return
}
}
match field.validate_range(options.from, options.to, options.steps) {
Ok(_) => ()
Err(message) => {
println(message)
return
}
}
let rows = @lab.sweep(
scenario,
field,
options.from,
options.to,
options.steps,
options.algorithms,
seeds=options.seeds,
)
if options.as_json {
println(
@lab.sweep_json(
scenario,
field,
rows,
options.algorithms,
seeds=options.seeds,
),
)
} else {
println(@lab.sweep_lines(scenario, field, rows, options.algorithms))
}
}
///|
/// The options `compare` and `sweep` share.
struct CommonOptions {
scenario : @lab.Scenario?
algorithms : Array[String]
field : String
from : Double
to : Double
steps : Int
seeds : Int
as_json : Bool
}
///|
/// Read the scenario file and the options both commands accept.
fn parse_common(
args : Array[String],
usage : String,
) -> Result[CommonOptions, String] {
if args.length() < 3 {
println("usage: moonnet " + usage)
return Err("missing arguments")
}
let scenario = match load_scenario(args[2]) {
Ok(value) => value
Err(message) => {
println(message)
return Err(message)
}
}
let mut algorithms : Array[String] = ["reno"]
let mut field = "loss"
let mut from = 0.0
let mut to = 0.01
let mut steps = 5
let mut seeds = 1
let mut as_json = false
let mut index = 3
while index < args.length() {
let option = args[index]
let has_value = index + 1 < args.length()
if option == "--cc" && has_value {
match @lab.algorithms_of(args[index + 1]) {
Ok(value) => algorithms = value
Err(message) => {
println(message)
return Err(message)
}
}
index = index + 2
} else if option == "--field" && has_value {
field = args[index + 1]
index = index + 2
} else if option == "--from" && has_value {
from = match parse_number(args[index + 1]) {
Ok(value) => value
Err(message) => {
println(message)
return Err(message)
}
}
index = index + 2
} else if option == "--to" && has_value {
to = match parse_number(args[index + 1]) {
Ok(value) => value
Err(message) => {
println(message)
return Err(message)
}
}
index = index + 2
} else if option == "--steps" && has_value {
steps = match parse_integer(args[index + 1], "steps") {
Ok(value) => value
Err(message) => {
println(message)
return Err(message)
}
}
index = index + 2
} else if option == "--json" {
as_json = true
index = index + 1
} else if option == "--seeds" && has_value {
seeds = match parse_integer(args[index + 1], "seeds") {
Ok(value) => value
Err(message) => {
println(message)
return Err(message)
}
}
index = index + 2
} else {
println("unknown option: " + option)
return Err(option)
}
}
Ok({
scenario: Some(scenario),
algorithms,
field,
from,
to,
steps,
seeds,
as_json,
})
}
///|
/// Read and check a scenario file, reporting what was wrong with it.
fn load_scenario(path : String) -> Result[@lab.Scenario, String] {
let text = @fs.read_file_to_string(path) catch {
_ => return Err("cannot read " + path)
}
let document = match @json.parse(text) {
Ok(value) => value
Err(message) => return Err(path + " is not valid JSON, " + message)
}
match @lab.Scenario::from_json(document) {
Ok(value) => Ok(value)
Err(message) => Err(path + " is not a usable scenario: " + message)
}
}
///|
/// Render an archived trace JSON document as a standalone SVG on stdout.
///
/// moonnet plot trace.json > trace.svg
fn plot_trace(args : Array[String]) -> Unit {
if args.length() != 3 {
println("usage: moonnet plot > trace.svg")
return
}
let path = args[2]
let source = match read_plot_input(path) {
Ok(value) => value
Err(message) => {
println(message)
return
}
}
match @lab.trace_svg_from_json(source) {
Ok(svg) => println(svg)
Err(message) => println("cannot render trace: " + message)
}
}
///|
/// Read UTF-8 files and PowerShell's common UTF-16 redirected output.
fn read_plot_input(path : String) -> Result[String, String] {
let bytes = @fs.read_file_to_bytes(path) catch {
_ => return Err("cannot read " + path)
}
match decode_plot_bytes(bytes) {
Ok(value) => Ok(value)
Err(message) => Err("cannot decode " + path + ": " + message)
}
}
///|
/// Decode UTF-8 and BOM-marked UTF-16 text used by redirected CLI output.
fn decode_plot_bytes(bytes : Bytes) -> Result[String, String] {
let first = match bytes.get(0) {
Some(value) => value.to_int()
None => 0
}
let second = match bytes.get(1) {
Some(value) => value.to_int()
None => 0
}
if first == 255 && second == 254 {
let text = @utf16.decode(
bytes,
ignore_bom=true,
endianness=@utf16.Endian::Little,
) catch {
_ => return Err("invalid UTF-16LE")
}
Ok(text)
} else if first == 254 && second == 255 {
let text = @utf16.decode(
bytes,
ignore_bom=true,
endianness=@utf16.Endian::Big,
) catch {
_ => return Err("invalid UTF-16BE")
}
Ok(text)
} else {
Ok(@utf8.decode_lossy(bytes, ignore_bom=true))
}
}
///|
fn parse_number(text : String) -> Result[Double, String] {
let value = @string.parse_double(text) catch {
_ => return Err("\"" + text + "\" is not a number")
}
Ok(value)
}
///|
fn parse_integer(text : String, option : String) -> Result[Int, String] {
let value = match parse_number(text) {
Ok(value) => value
Err(message) => return Err(message)
}
let integer = value.to_int()
if value != integer.to_double() {
Err("--" + option + " must be a whole number")
} else {
Ok(integer)
}
}
///|
/// Run an experiment described by a scenario file.
///
/// This is the command the whole project has been building towards: the numbers
/// come from the same library the tests exercise, and the experiment itself is a
/// document anyone can edit and re-run.
///
/// moonnet run scenarios/long-fat.json --cc cubic
/// moonnet run scenarios/long-fat.json --json > report.json
fn run_scenario(args : Array[String]) -> Unit {
if args.length() < 3 {
println(
"usage: moonnet run [--cc none|reno|cubic] [--discipline drop-tail|red|codel] [--json] [--trace]",
)
return
}
let path = args[2]
let mut algorithm = ""
let mut discipline = ""
let mut as_json = false
let mut trace = false
let mut index = 3
while index < args.length() {
if args[index] == "--cc" && index + 1 < args.length() {
algorithm = args[index + 1]
index = index + 2
} else if args[index] == "--discipline" && index + 1 < args.length() {
discipline = args[index + 1]
index = index + 2
} else if args[index] == "--json" {
as_json = true
index = index + 1
} else if args[index] == "--trace" {
trace = true
index = index + 1
} else {
println("unknown option: " + args[index])
return
}
}
let text = @fs.read_file_to_string(path) catch {
_ => {
println("cannot read " + path)
return
}
}
let document = match @json.parse(text) {
Ok(value) => value
Err(message) => {
println(path + " is not valid JSON, " + message)
return
}
}
let scenario = match @lab.Scenario::from_json(document) {
Ok(value) => value
Err(message) => {
println(path + " is not a usable scenario: " + message)
return
}
}
if algorithm != "" {
match @lab.known_algorithm(algorithm) {
Ok(_) => ()
Err(message) => {
println(message)
return
}
}
}
if discipline != "" {
match @lab.known_discipline(discipline) {
Ok(_) => ()
Err(message) => {
println(message)
return
}
}
}
// The override rewrites the uplink, which is the link that carries the data in
// every scenario in this repository. Rewriting both ends would silently change
// the return path as well, and a run is supposed to vary one thing.
let with_cc = if algorithm == "" {
scenario
} else {
{ ..scenario, algorithm, }
}
let experiment = if discipline == "" {
with_cc
} else {
{ ..with_cc, uplink: { ..with_cc.uplink, discipline, }, }
}
// A scenario that names several flows is a fairness experiment: the flows
// share one path, so the interesting output is per-flow rather than per-run.
if experiment.flow_algorithms().length() > 1 {
if trace {
println("--trace currently supports single-flow scenarios")
return
}
let fairness = @lab.run_fairness(experiment)
if as_json {
println(fairness.to_json_text())
} else {
println(fairness.to_lines())
}
} else if trace {
let traced = @lab.run_with_trace(experiment)
if as_json {
println(traced.to_json_text())
} else {
println(traced.to_lines())
}
} else {
let report = @lab.run(experiment)
if as_json {
println(report.to_json_text())
} else {
println(report.to_lines())
}
}
}
///|
/// List the scenario files in the directory the tool expects them in.
fn list_scenarios() -> Unit {
let names = @fs.read_dir("scenarios") catch {
_ => {
println("no scenarios directory here; run this from the project root")
return
}
}
println("scenarios:")
for name in names {
if name.has_suffix(".json") {
println(" " + name)
}
}
}
///|
/// The congestion control comparison: the same lossy path, the same seed, one
/// difference — whether the sender is allowed to ignore congestion.
///
/// The interesting column is not the transfer time. Uncontrolled, the sender
/// pushes into a queue that has already dropped a packet, and the retransmission
/// races the packets behind it. Controlled, it halves its window on the first
/// evidence of loss, which costs throughput here and is the entire reason the
/// network does not collapse when everyone does it at once.