diff --git a/README.md b/README.md index 8204b119d7b..31bc6203f0a 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,13 @@ To serve your config, do: `workerd serve my-config.capnp` +To check a config without serving it, do: + +`workerd check my-config.capnp` + +This builds every service and compiles every Worker, but binds no socket. It reports every +error rather than only the first, and exits with status 1 if there was any. + For more details about command-line usage, use `workerd --help`. Prebuilt binaries are distributed via `npm`. Run `npx workerd ...` to use these. If you're running a prebuilt binary, you'll need to make sure your system has the right dependencies installed: diff --git a/src/workerd/server/AGENTS.md b/src/workerd/server/AGENTS.md index 16a31a8ea43..c1ab083c1fb 100644 --- a/src/workerd/server/AGENTS.md +++ b/src/workerd/server/AGENTS.md @@ -4,7 +4,7 @@ The `workerd` binary and its server. `:workerd` (`workerd.rs`, this package) is a Rust binary over three crates in this directory: -- `cli/` -- the `workerd-cli` crate (declared in this package's `BUILD.bazel`, sources globbed from `cli/`): parses the command line (clap), produces the encoded config (schema files are compiled by this package's `config-compiler.c++`), handles `compile`, `--watch` and re-exec, and hands `serve`, `test` (which `fuzzilli` and `make-pyodide-baseline-snapshot` are built on) and `bench` to the server crate's `entry` module. +- `cli/` -- the `workerd-cli` crate (declared in this package's `BUILD.bazel`, sources globbed from `cli/`): parses the command line (clap), produces the encoded config (schema files are compiled by this package's `config-compiler.c++`), handles `compile`, `--watch` and re-exec, and hands `serve`, `check`, `test` (which `fuzzilli` and `make-pyodide-baseline-snapshot` are built on) and `bench` to the server crate's `entry` module. - `server/` -- the `workerd-server` crate: the single-tenant server. It interprets the config, builds the service graph, binds the sockets, runs the listeners, actors and dynamic workers, drains on SIGTERM, and runs `workerd test` and `workerd bench`. `Error` is `cxx::KjError`; `bridge.rs` is the one module that allows `unsafe`. - `factory/` -- C++: `WorkerFactory` (`worker-factory*`), everything that needs the isolate, driven by the server through the cxx bridge in `server/bridge.rs`; and `bootstrap.{h,c++}`, the C++ half of a command. `with_process_context` sets up the process's logging (the JSON logger, when the config asks for structured logging) on the C++ stack around the Rust command; `bootstrap` sets up perfetto, autogates and V8 on the Rust-owned event loop and builds the `WorkerFactory`, which owns them, from the encoded config and the command-line options for `server/entry.rs`, which owns the loop (`kj_rs_tokio::Runtime`) and blocks on `server_serve` / `server_test` / `server_bench`. - `cluster/` -- the `cluster` crate and, in `cluster/logic/`, `cluster-logic`: cluster mode (`Config.cluster`, experimental), Durable Objects whose shared state is an S3-compatible bucket. Leases, ownership, LTX capture and restore; pure Rust, driven by `server/cluster.rs`. `cluster-logic` holds decisions only, with no dependencies to do I/O with. @@ -67,8 +67,8 @@ Bazel labels: `//src/workerd/server:workerd`, `//src/workerd/server:workerd-cli` | ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `lib.rs` | Crate root: `Error` / `Result` | | `bridge.rs` | The cxx bridge (`workerd::server`) to `factory/worker-factory.h` and `factory/bootstrap.h`. `extern "C++"`: the factory (compile, start request, bench, actors and storage, RPC, tokens, inspector, `factory_spawn`), the bootstrap, the process's logging and its exit; `extern "Rust"`: the channel objects, the channel factory, the pending command. Ownership rules in its `//!` | -| `entry.rs` | `serve` / `test` / `bench`: the process's schedule (`with_process_context`, the `kj_rs_tokio::Runtime`, `bootstrap`, `block_on`, exit), `server_serve` / `server_test` / `server_bench`, the `Watch` future the command line supplies, printing config errors and the exit status they make, SIGTERM drain, `--watch` reload, shutdown order (settle tasks, drop the factory and with it V8 and the perfetto session, then the Runtime) | -| `run.rs` | `run()` / `test()` / `bench()`: bind sockets, start the inspector (before any isolate exists), build the graph, start the listeners (not for a config the `Reporter` refuses), drain, control-fd events, the check that every `--directory-path` / `--external-addr` override named a service | +| `entry.rs` | `serve` / `check` / `test` / `bench`: the process's schedule (`with_process_context`, the `kj_rs_tokio::Runtime`, `bootstrap`, `block_on`, exit), `server_serve` / `server_check` / `server_test` / `server_bench`, the `Watch` future the command line supplies, printing config errors and the exit status they make, SIGTERM drain, `--watch` reload, shutdown order (settle tasks, drop the factory and with it V8 and the perfetto session, then the Runtime) | +| `run.rs` | `run()` / `test()` / `bench()`, and `check()`, which only builds the graph: bind sockets, start the inspector (before any isolate exists), build the graph, start the listeners (not for a config the `Reporter` refuses), drain, control-fd events, the check that every `--directory-path` / `--external-addr` override named a service | | `bench.rs` | `workerd bench`'s options and report (`io/bench.capnp`): a group for each `bench()` handler `run::bench` runs (through the factory's `worker_bench`), progress logged as `workerd test` logs it, the environment, and the report as a table or as JSON | | `config.rs` | `Server`: the service graph from the config in three passes (actor configs, construction, link); `Factory`, the Rust owner of the C++ `WorkerFactory`; `Reporter`; resolves channel tokens and debug-port requests | | `channels.rs` | The handles C++ holds and the runtime reaches into the server through: `SubrequestChannel` (an `Rc`), `ActorClassChannel` (an `Rc`), `ChannelFactory` (a worker's `LinkedChannels`), `WorkerStub`, and the `Weak` back-pointers (`ServerHandle`, `ActorNamespaceHandle`, `ActorHooks`). Single-threaded | @@ -99,7 +99,7 @@ Bazel labels: `//src/workerd/server:workerd`, `//src/workerd/server:workerd-cli` - **Rust unit tests**: a module's tests are in the `-test.rs` beside it (`config-test.rs` for `config.rs`, `listen/mod-test.rs` for `listen/mod.rs`), compiled only into the crates' test targets (`//src/workerd/server/server:workerd-server_test`, `//src/workerd/server:workerd-cli_test`); see `src/rust/AGENTS.md`, UNIT TEST FILES. The server crate's test binary links `//src/workerd/util:setup-async-io` because the C++ it reaches calls `kj::setupAsyncIo()`. - **C++ unit tests** (`*-test.c++`, `kj_test`): alarm scheduler, channel tokens, container client, facet tree index, fallback service, JSON logger, actor IDs. - **`server-test.c++`** (`//src/workerd/server:server-test@`): the server end to end, in the test process. Each `TestServer` builds a `WorkerFactory` on the test's V8, timer, network and a temp directory, and hands it to `InProcessServer` (`server/in_process.rs`, through the server crate's bridge), which runs `run::run` / `run::test` / `run::bench` on it and queues the config errors and warnings for the test. No connection but UDP's leaves the process: the config's socket and external addresses become `loopback:` names, the test connects to the server's sockets and accepts the server's outbound connections by name (the `network` services' too), and the factory's own C++ clients (Docker, the debug port) dial the same names through the test's `kj::Network`. The factory's timer is the test's mock `kj::TimerImpl`, which `test.wait(n)` advances; the server's own delays run on it too (`Factory::sleep` / `Factory::now`, `server/config.rs`). An unexpected error log, config error or warning fails the case, as does a factory still referenced once the server is closed. Add a case here for every new config error or warning. -- **`tests/`**: `server-harness.mjs`, a Node.js harness spawning the `workerd` binary for E2E tests; it reads the `listen` events off `--control-fd`. Subdirectories: `compile-tests`, `container-client`, `container-shutdown`, `extensions`, `inspector`, `module-fallback`, `python`, `socket-close`, `structured-logging`, `udp-connect`, `udp-connect-ts-streams`, `udp-datagram-drop`, `unsafe-eval`, `unsafe-module`, `weakref`, `wrapped-binding-serialization`. Every `.wd-test` in the repository also runs through `workerd test` (`wd_test` in `build/wd_test.bzl`, over `:workerd`), and every `wd_bench()` config through `workerd bench --quick`, in its `@smoke` tests (`build/wd_bench.bzl`). +- **`tests/`**: `server-harness.mjs`, a Node.js harness spawning the `workerd` binary for E2E tests; it reads the `listen` events off `--control-fd`. Subdirectories: `check` (`workerd check`, as a shell test), `compile-tests`, `container-client`, `container-shutdown`, `extensions`, `inspector`, `module-fallback`, `python`, `socket-close`, `structured-logging`, `udp-connect`, `udp-connect-ts-streams`, `udp-datagram-drop`, `unsafe-eval`, `unsafe-module`, `weakref`, `wrapped-binding-serialization`. Every `.wd-test` in the repository also runs through `workerd test` (`wd_test` in `build/wd_test.bzl`, over `:workerd`), and every `wd_bench()` config through `workerd bench --quick`, in its `@smoke` tests (`build/wd_bench.bzl`). - **kj-hyper contract tests** (`src/rust/cxx/kj-hyper/tests`): HTTP and WebSocket semantics against kj's interfaces. - `:rust-io-link-check` inspects the linked binary's symbols (unix): kj's own `setupAsyncIo()` must not be linked in. diff --git a/src/workerd/server/cli/args-test.rs b/src/workerd/server/cli/args-test.rs index 4787dcaa452..80b6afbf4dc 100644 --- a/src/workerd/server/cli/args-test.rs +++ b/src/workerd/server/cli/args-test.rs @@ -42,6 +42,33 @@ fn wd_test_invocation() { ); } +#[test] +fn check_invocation() { + let Command::Check { + config, + const_name, + serve_or_test, + } = parse(&[ + "check", + "config.capnp", + "myConfig", + "--experimental", + "-dstorage=/tmp/x", + ]) + .command + else { + panic!("expected check") + }; + assert_eq!(config.config_file, "config.capnp"); + assert_eq!(const_name.const_name.as_deref(), Some("myConfig")); + assert!(serve_or_test.experimental); + assert_eq!(serve_or_test.directory_overrides.len(), 1); + // `check` serves nothing, so it takes none of `serve`'s socket options. + assert!( + Main::try_parse_from(["workerd", "check", "c.capnp", "--socket-addr=http=*:0"]).is_err() + ); +} + #[test] fn serve_options() { let Command::Serve { diff --git a/src/workerd/server/cli/args.rs b/src/workerd/server/cli/args.rs index 2a809003f84..3b2997eb8e6 100644 --- a/src/workerd/server/cli/args.rs +++ b/src/workerd/server/cli/args.rs @@ -77,6 +77,26 @@ pub enum Command { serve: ServeArgs, }, + /// check a config and report every problem, without serving + #[command( + long_about = "Loads a config and reports every problem with it, without serving.", + after_long_help = "Accepts the same as `serve`. Every service is built and \ + every Worker compiled, so errors in a Worker's top-level code are reported too, but no \ + socket is bound, so a check can run next to a server using the same ports. Every \ + error is reported, rather than only the first, followed by their count, or by \ + \"Config OK\" if there were none. Exits with status 1 if there was any error." + )] + Check { + #[command(flatten)] + config: ConfigFileArgs, + + #[command(flatten)] + const_name: ConstNameArg, + + #[command(flatten)] + serve_or_test: ServeOrTestArgs, + }, + /// create a self-contained binary #[command( long_about = "Builds a self-contained binary from a config.", diff --git a/src/workerd/server/cli/lib.rs b/src/workerd/server/cli/lib.rs index 67d262307ea..2142d0db69f 100644 --- a/src/workerd/server/cli/lib.rs +++ b/src/workerd/server/cli/lib.rs @@ -129,6 +129,17 @@ fn run_command(args: args::Main, executable: Option) -> workerd_serv watching(process), ) } + args::Command::Check { + config, + const_name, + serve_or_test, + } => run_check( + verbose, + config, + const_name.const_name, + &serve_or_test, + executable, + ), args::Command::Compile { config, const_name, @@ -193,6 +204,34 @@ fn run_command(args: args::Main, executable: Option) -> workerd_serv } } +/// `workerd check`: every error in the config, without serving it. Options that only make sense +/// for a running server are usage errors rather than silently ignored. +fn run_check( + verbose: bool, + config: args::ConfigFileArgs, + const_name: Option, + serve_or_test: &args::ServeOrTestArgs, + executable: Option, +) -> workerd_server::Result { + type T = args::Main; + + check_serve_or_test::(serve_or_test); + if serve_or_test.watch { + usage_error::("--watch: `check` does not serve, so has nothing to reload."); + } + if serve_or_test.inspector_addr.is_some() { + usage_error::("--inspector-addr: `check` does not serve, so starts no inspector."); + } + let process = Process::new(executable, None, Vec::new()); + let config = load_config::(config, const_name, &process); + entry::check( + verbose, + config.into_words(), + serve_or_test.into(), + run_options(serve_or_test).overrides, + ) +} + /// `workerd compile`: the config, encoded, either on its own (`--config-only`) or appended to a /// copy of this executable. fn run_compile( diff --git a/src/workerd/server/server/config-test.rs b/src/workerd/server/server/config-test.rs index 68256665735..9a6122b27c3 100644 --- a/src/workerd/server/server/config-test.rs +++ b/src/workerd/server/server/config-test.rs @@ -227,6 +227,37 @@ fn lookup_of_a_missing_service_is_reported() { ); } +#[test] +fn lookup_of_a_misspelled_service_suggests_the_closest_name() { + let services = leaf_services(); + let (report, errors) = collecting_reporter(); + lookup_service(&services, &report, &designator("nett", None)); + lookup_actor_class(&services, &report, &designator("NET", None)); + assert_eq!( + *errors.borrow(), + [ + "Worker \"w\"'s binding \"b\" refers to a service \"nett\", but no such service \ + is defined. Did you mean \"net\"?", + "Worker \"w\"'s binding \"b\" refers to a service \"NET\", but no such service \ + is defined. Did you mean \"net\"?", + ] + ); +} + +#[test] +fn did_you_mean_suggests_only_close_names() { + let names = ["main", "internet", "storage"]; + assert_eq!(did_you_mean("mian", names), " Did you mean \"main\"?"); + assert_eq!(did_you_mean("MAIN", names), " Did you mean \"main\"?"); + assert_eq!( + did_you_mean("interet", names), + " Did you mean \"internet\"?" + ); + assert_eq!(did_you_mean("database", names), ""); + assert_eq!(did_you_mean("main", names), ""); + assert_eq!(did_you_mean("x", []), ""); +} + #[test] fn lookup_of_a_leaf_with_an_entrypoint_is_reported() { let services = leaf_services(); diff --git a/src/workerd/server/server/config.rs b/src/workerd/server/server/config.rs index 9be554cc1e0..f75488f9469 100644 --- a/src/workerd/server/server/config.rs +++ b/src/workerd/server/server/config.rs @@ -893,6 +893,49 @@ impl<'a> WorkflowEngine<'a> { } } +/// The Levenshtein distance between `a` and `b`, ignoring ASCII case. +fn edit_distance(a: &str, b: &str) -> usize { + let a: Vec = a.bytes().map(|c| c.to_ascii_lowercase()).collect(); + let b: Vec = b.bytes().map(|c| c.to_ascii_lowercase()).collect(); + let mut prev: Vec = (0..=b.len()).collect(); + let mut cur = vec![0; b.len() + 1]; + for (i, &ca) in a.iter().enumerate() { + cur[0] = i + 1; + for (j, &cb) in b.iter().enumerate() { + let cost = usize::from(ca != cb); + cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost); + } + std::mem::swap(&mut prev, &mut cur); + } + prev[b.len()] +} + +/// A hint for an error about a name that is not defined: ` Did you mean "x"?`, naming the closest +/// of `candidates`, or empty when none is close enough to be worth suggesting. +pub fn did_you_mean<'a>(name: &str, candidates: impl IntoIterator) -> String { + let threshold = (name.len() / 3).max(2); + let best = candidates + .into_iter() + .filter(|candidate| *candidate != name) + .map(|candidate| (edit_distance(name, candidate), candidate)) + .filter(|(distance, _)| *distance <= threshold) + .min_by_key(|(distance, _)| *distance); + best.map_or_else(String::new, |(_, candidate)| { + format!(" Did you mean \"{candidate}\"?") + }) +} + +/// The error for a designator whose service is not defined, with a hint naming the closest +/// service that is. +fn no_such_service( + context: &str, + target: &str, + services: &LinkedHashMap, +) -> String { + let hint = did_you_mean(target, services.keys().map(String::as_str)); + format!("{context} refers to a service \"{target}\", but no such service is defined.{hint}") +} + /// What a `ServiceDesignator` resolves to, or the invalid-config stand-in after the error was /// reported. pub fn lookup_service( @@ -907,9 +950,7 @@ pub fn lookup_service( error_context: context, } = designator; let Some(service) = services.get(target) else { - report.error(format!( - "{context} refers to a service \"{target}\", but no such service is defined." - )); + report.error(no_such_service(context, target, services)); return Rc::new(InvalidConfigChannel); }; let props = configured_props(props_json.as_deref()); @@ -966,9 +1007,7 @@ pub fn lookup_actor_class( error_context: context, } = designator; let Some(service) = services.get(target) else { - report.error(format!( - "{context} refers to a service \"{target}\", but no such service is defined." - )); + report.error(no_such_service(context, target, services)); return Rc::new(InvalidConfigActorClass); }; let props = configured_props(props_json.as_deref()); diff --git a/src/workerd/server/server/entry.rs b/src/workerd/server/server/entry.rs index edd0678c3fa..b5a9d77be80 100644 --- a/src/workerd/server/server/entry.rs +++ b/src/workerd/server/server/entry.rs @@ -2,17 +2,18 @@ // Licensed under the Apache 2.0 license found in the LICENSE file or at: // https://opensource.org/licenses/Apache-2.0 -//! The `serve`, `test` and `bench` commands: the command line's Rust half, and the process's -//! schedule. +//! The `serve`, `check`, `test` and `bench` commands: the command line's Rust half, and the +//! process's schedule. //! //! The command line (cli/lib.rs) parses the arguments into [`RunOptions`] and calls -//! [`serve`] / [`test`] / [`bench`]. Each creates the event loop -- a `kj_rs_tokio::Runtime`, the -//! KJ loop with tokio running whenever it sleeps -- has the C++ bootstrap -//! (bootstrap.c++) set up the process (logging, perfetto, V8) and build the worker factory on that -//! loop, then blocks on `server_serve` / `server_test` / `server_bench`, which run the server -//! (`run.rs`). Every config error is printed, and the server then does not serve; unless -//! `--watch` is on, in which case it serves what it can, waits for the config to change and the -//! process re-executes itself. +//! [`serve`] / [`check`] / [`test`] / [`bench`]. Each creates the event loop -- a +//! `kj_rs_tokio::Runtime`, the KJ loop with tokio running whenever it sleeps -- has the C++ +//! bootstrap (bootstrap.c++) set up the process (logging, perfetto, V8) and build the worker +//! factory on that loop, then blocks on `server_serve` / `server_check` / `server_test` / +//! `server_bench`, which run the server (`run.rs`). Every config error is printed, and the server +//! then does not serve; unless `--watch` is on, in which case it serves what it can, waits for +//! the config to change and the process re-executes itself. `check` never serves: it builds the +//! services, prints every error and then how many there were. //! //! Exit: status 0 when the run completes, 1 if any error was reported or the run failed (the //! failure is printed as `*** Uncaught exception ***`). However the run ended, the factory has @@ -20,6 +21,7 @@ //! set the process ends right there, without running the remaining destructors, like //! `kj::ProcessContext::exit()`; with it, the Runtime is torn down and `main` returns the status. +use std::cell::Cell; use std::convert::Infallible; use std::future::Future; use std::rc::Rc; @@ -35,6 +37,7 @@ use crate::bench; use crate::bridge::ffi; use crate::channels::AbortReason; use crate::config::Factory; +use crate::config::Overrides; use crate::config::Reporter; use crate::log::Severity; use crate::run::RunOptions; @@ -58,6 +61,18 @@ pub fn serve( run(verbose, config, options, None, None, command) } +/// Runs `workerd check` (see the module doc): reports every config error, then their count or +/// that the config is OK. Nothing is served. The exit status, if the process is to go on. +pub fn check( + verbose: bool, + config: Vec, + options: ffi::ServeOrTestOptions, + overrides: Overrides, +) -> Result { + let command = move |factory, structured| server_check(factory, overrides, structured); + run(verbose, config, options, None, None, command) +} + /// Runs `workerd test` (see the module doc); the exit status, if the process is to go on. /// `patterns` are the `` and `` globs. pub fn test( @@ -240,6 +255,51 @@ async fn server_serve( shutdown } +/// `workerd check`: builds `factory`'s config without serving it (`run::check`), then reports +/// how many config errors there were, or that the config is OK. +async fn server_check( + factory: KjOwn, + overrides: Overrides, + structured: bool, +) -> Result<()> { + crate::log::install(); + let errors = Rc::new(Cell::new(0usize)); + let counted = Rc::clone(&errors); + let counting = Reporter::new( + Box::new(move |message| { + counted.set(counted.get() + 1); + error(structured, &message); + }), + Box::new(move |message| report(structured, Severity::Warning, &message)), + // Nothing is served, so there is nothing to refuse. + true, + ); + let factory = Rc::new(Factory::new(factory)); + let result = crate::run::check(Rc::clone(&factory), &overrides, counting).await; + let shutdown = shutdown(factory).await; + let summary = result?; + shutdown?; + let plural = |n: usize| if n == 1 { "" } else { "s" }; + match errors.get() { + 0 => report( + structured, + Severity::Info, + &format!( + "Config OK: {} service{}, {} socket{}.", + summary.services, + plural(summary.services), + summary.sockets, + plural(summary.sockets) + ), + ), + count => error( + structured, + &format!("{count} config error{} found.", plural(count)), + ), + } + Ok(()) +} + /// Tears the run down once the server is gone: ends the factory's background tasks, then drops /// the factory and, with it, the last of the isolates, V8 and the perfetto session. A factory /// still shared at this point is a leaked service. diff --git a/src/workerd/server/server/run.rs b/src/workerd/server/server/run.rs index 184b0b571a1..43de6c5eda7 100644 --- a/src/workerd/server/server/run.rs +++ b/src/workerd/server/server/run.rs @@ -4,6 +4,8 @@ //! `workerd serve`, `workerd test` and `workerd bench`: bind the sockets, build the service //! graph, listen, and either wait for the drain signal or run the tests or benchmarks. +//! +//! `workerd check` builds the graph only. use std::collections::HashMap; use std::future::Future; @@ -177,17 +179,7 @@ async fn start( continue; }; let name = text(sock.get_name())?; - let mut errors = Vec::new(); - let designator = Designator::from_reader( - sock.get_service().map_err(capnp_error)?, - format!("Socket \"{name}\""), - &mut errors, - ) - .map_err(capnp_error)?; - for error in errors { - server.report().error(error); - } - let channel = server.lookup(&designator); + let channel = socket_channel(&server, sock, &name)?; ports.push((name, listen::bound_port(&bound)?)); if let Some(listener) = listener(Rc::clone(&context), sock, bound, &address, channel, &server)? @@ -222,6 +214,25 @@ async fn start( })) } +/// The channel socket `name` serves, after reporting any error in its service designator. +fn socket_channel( + server: &Server, + sock: socket::Reader<'_>, + name: &str, +) -> Result> { + let mut errors = Vec::new(); + let designator = Designator::from_reader( + sock.get_service().map_err(capnp_error)?, + format!("Socket \"{name}\""), + &mut errors, + ) + .map_err(capnp_error)?; + for error in errors { + server.report().error(error); + } + Ok(server.lookup(&designator)) +} + /// Runs this instance's part in the cluster: the peer port, the lease, and compaction. They run for /// the life of the process; a lease the instance cannot keep ends it. fn start_cluster(factory: &Rc, node: &Rc, listener: tokio::net::TcpListener) { @@ -438,6 +449,47 @@ pub async fn run( Ok(ffi::factory_shutdown_containers(server.factory().raw()).await?) } +/// What `workerd check` found in a config. +pub struct CheckSummary { + pub services: usize, + pub sockets: usize, +} + +/// `workerd check`: builds the service graph, compiling every Worker, and resolves every +/// socket's service, without binding a socket or joining a cluster. Config errors go to +/// `report`. +/// +/// A socket without an address is a warning rather than an error: `workerd serve` accepts its +/// address on the command line. +pub async fn check( + factory: Rc, + overrides: &Overrides, + report: Reporter, +) -> Result { + let message = factory.config()?; + let config = message.get_root::().map_err(capnp_error)?; + let sockets = config.get_sockets().map_err(capnp_error)?; + for sock in sockets { + if !sock.has_address() { + let name = text(sock.get_name())?; + report.warning(format!( + "Socket \"{name}\" has no address in the config, so `workerd serve` needs one \ + on the command line with `--socket-addr` or `--socket-fd`." + )); + } + } + let server = + Server::start(Rc::clone(&factory), overrides, report, HashMap::new(), None).await?; + report_unmatched_overrides(&server, overrides, config, false)?; + for sock in sockets { + socket_channel(&server, sock, &text(sock.get_name())?)?; + } + Ok(CheckSummary { + services: config.get_services().map_err(capnp_error)?.len() as usize, + sockets: sockets.len() as usize, + }) +} + /// The entrypoints `workerd test` or `workerd bench` runs a handler of: the channel of each, /// and its name ("service" or "service:entrypoint"). type Targets = Vec<(Rc, String)>; diff --git a/src/workerd/server/tests/check/BUILD.bazel b/src/workerd/server/tests/check/BUILD.bazel new file mode 100644 index 00000000000..953a20f0336 --- /dev/null +++ b/src/workerd/server/tests/check/BUILD.bazel @@ -0,0 +1,18 @@ +load("@rules_shell//shell:sh_test.bzl", "sh_test") + +sh_test( + name = "check-test", + size = "small", + srcs = ["check-test.sh"], + args = [ + "$(location //src/workerd/server:workerd)", + "$(location ok.capnp)", + ], + data = glob([ + "*.capnp", + "*.js", + "wrangler/*", + ]) + [ + "//src/workerd/server:workerd", + ], +) diff --git a/src/workerd/server/tests/check/check-test.sh b/src/workerd/server/tests/check/check-test.sh new file mode 100755 index 00000000000..85e26d4db27 --- /dev/null +++ b/src/workerd/server/tests/check/check-test.sh @@ -0,0 +1,63 @@ +#!/bin/bash + +# `workerd check`: its exit status, and the errors, warnings and summary it prints. +# +# $1 -> workerd binary path +# $2 -> path to ok.capnp, beside the test's other configs + +set -euo pipefail + +WORKERD_BINARY=$1 +CONFIGS=$(dirname "$2") +OUTPUT=$TEST_TMPDIR/output +FAILED=0 + +# Runs `workerd check` on the given arguments, expecting the exit status $1, and leaves its +# output in $OUTPUT. +check() { + local expected=$1 + shift + local status=0 + "$WORKERD_BINARY" check "$@" > "$OUTPUT" 2>&1 || status=$? + if [[ $status != "$expected" ]]; then + echo "FAIL: \`workerd check $*\` exited with status $status, expected $expected:" >&2 + cat "$OUTPUT" >&2 + FAILED=1 + fi +} + +# Expects $OUTPUT to contain the line $1. +expect_line() { + if ! grep -qxF -- "$1" "$OUTPUT"; then + echo "FAIL: expected the line: $1" >&2 + echo "in the output:" >&2 + cat "$OUTPUT" >&2 + FAILED=1 + fi +} + +check 0 "$CONFIGS/ok.capnp" +expect_line 'Socket "admin" has no address in the config, so `workerd serve` needs one on the command line with `--socket-addr` or `--socket-fd`.' +expect_line "Config OK: 2 services, 2 sockets." + +# Every error is reported, not only the first: a Worker whose top-level code throws, and two +# misspelled service names, each with a hint. +check 1 "$CONFIGS/errors.capnp" +expect_line "service broken: Uncaught Error: failed at startup" +expect_line "Worker \"main\"'s binding \"OTHER\" refers to a service \"mian\", but no such service is defined. Did you mean \"main\"?" +expect_line 'Socket "http" refers to a service "mainn", but no such service is defined. Did you mean "main"?' +expect_line "3 config errors found." + +# Overrides are checked against the config, as `serve` checks them. +check 1 "$CONFIGS/ok.capnp" --directory-path=nope=/tmp +expect_line 'Config did not define any disk service named "nope" to match the override provided on the command line.' +expect_line "1 config error found." + +check 0 "$CONFIGS/wrangler/wrangler.jsonc" +expect_line "Config OK: 1 service, 1 socket." + +# Options for a running server are refused rather than ignored. +check 1 "$CONFIGS/ok.capnp" --watch +expect_line 'error: --watch: `check` does not serve, so has nothing to reload.' + +exit $FAILED diff --git a/src/workerd/server/tests/check/errors.capnp b/src/workerd/server/tests/check/errors.capnp new file mode 100644 index 00000000000..e41f8374812 --- /dev/null +++ b/src/workerd/server/tests/check/errors.capnp @@ -0,0 +1,22 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .main), + (name = "broken", worker = .broken), + ], + sockets = [ + (name = "http", address = "localhost:1", http = (), service = "mainn"), + ], +); + +const main :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + modules = [(name = "main.js", esModule = embed "ok.js")], + bindings = [(name = "OTHER", service = "mian")], +); + +const broken :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + modules = [(name = "main.js", esModule = embed "throws.js")], +); diff --git a/src/workerd/server/tests/check/ok.capnp b/src/workerd/server/tests/check/ok.capnp new file mode 100644 index 00000000000..26a3cb49228 --- /dev/null +++ b/src/workerd/server/tests/check/ok.capnp @@ -0,0 +1,25 @@ +using Workerd = import "/workerd/workerd.capnp"; + +const config :Workerd.Config = ( + services = [ + (name = "main", worker = .main), + (name = "other", worker = .other), + ], + sockets = [ + # Port 1 is privileged: `check` binds no socket, so it never tries. + (name = "http", address = "localhost:1", http = (), service = "main"), + # No address: `serve` needs --socket-addr, which `check` warns about. + (name = "admin", http = (), service = "other"), + ], +); + +const main :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + modules = [(name = "main.js", esModule = embed "ok.js")], + bindings = [(name = "OTHER", service = "other")], +); + +const other :Workerd.Worker = ( + compatibilityDate = "2025-01-01", + modules = [(name = "main.js", esModule = embed "ok.js")], +); diff --git a/src/workerd/server/tests/check/ok.js b/src/workerd/server/tests/check/ok.js new file mode 100644 index 00000000000..a187155c36c --- /dev/null +++ b/src/workerd/server/tests/check/ok.js @@ -0,0 +1,5 @@ +export default { + async fetch() { + return new Response('ok'); + }, +}; diff --git a/src/workerd/server/tests/check/throws.js b/src/workerd/server/tests/check/throws.js new file mode 100644 index 00000000000..cb5f15b9920 --- /dev/null +++ b/src/workerd/server/tests/check/throws.js @@ -0,0 +1 @@ +throw new Error('failed at startup'); diff --git a/src/workerd/server/tests/check/wrangler/index.js b/src/workerd/server/tests/check/wrangler/index.js new file mode 100644 index 00000000000..a187155c36c --- /dev/null +++ b/src/workerd/server/tests/check/wrangler/index.js @@ -0,0 +1,5 @@ +export default { + async fetch() { + return new Response('ok'); + }, +}; diff --git a/src/workerd/server/tests/check/wrangler/wrangler.jsonc b/src/workerd/server/tests/check/wrangler/wrangler.jsonc new file mode 100644 index 00000000000..9cf9ada8425 --- /dev/null +++ b/src/workerd/server/tests/check/wrangler/wrangler.jsonc @@ -0,0 +1,7 @@ +{ + "name": "check-wrangler", + "main": "index.js", + "compatibility_date": "2025-01-01", + // Scanned as plain modules, so the test needs no esbuild. + "no_bundle": true, +}