Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions bynk-emit/src/emitter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ use std::sync::Arc;

use self::source_map::SourceMapBuilder;

use crate::ir::lower::{lower_capability_item_ir, lower_type_item_ir};
use crate::ir::lower::{
lower_capability_item_ir, lower_protocol_ir, lower_service_handler_signature_ir,
lower_type_item_ir,
};
use crate::ir::{IrItem, TypeShape};
use crate::project::{BuildTarget, EmitProjectCtx, ImportExt, UnitKind};
use bynk_check::builtin_names::map_query;
Expand Down Expand Up @@ -411,7 +414,30 @@ pub(crate) fn emit_project(
}
CommonsItem::Service(s) => {
smb.borrow_mut().record(out.len(), s.span);
emit_service(&mut out, s, commons, ctx, Some(&smb));
// #1187's slice 5: `emit_service` reads the protocol's own
// resolved data (`ProtocolIr`) and each handler's resolved
// signature (params/ret/effectful) instead of `s`'s own raw
// `ServiceProtocol`/`TypeRef`s — not a full `IrItem::Service`
// (see `lower_service_handler_signature_ir`'s own doc
// comment for why: a real `IrHandler` would unconditionally
// lower every handler's body, panicking on an ordinary
// `Ok`/`Err`-returning Http handler). No separate helper,
// this is `emit_service`'s one and only call site.
let protocol = lower_protocol_ir(&s.protocol, program);
let signatures: Vec<_> = s
.handlers
.iter()
.map(|h| lower_service_handler_signature_ir(h, program))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New ADR-0334 panic on the emit path for a case the checker doesn't actually reject.

lower_service_handler_signature_irlower_handler_signature_ir panics when a handler param's or return type's TypeRef fails to resolve:

let ty = cx.resolve_type_ref(&p.type_ref).unwrap_or_else(|| panic!(
    "bynk internal error (ADR 0334): handler parameter `{}`'s type does not resolve …
     but the checker already accepted this handler"))

That premise doesn't hold for service handlers specifically:

  • resolver.rs skips CommonsItem::Service in every type-ref-resolution pass (resolver.rs:575-583, and the two sibling sites lower_protocol_ir's own doc comment already cites) — so an unknown type name in a service handler signature never gets bynk.resolve.unknown_type.
  • check_handler_body then silently skips a param whose type doesn't resolve (bynk-check/src/checker.rs:1120-1135if let Some(t) = resolve_type_ref(...), no else branch, no diagnostic) and silently returns when the return type doesn't resolve (checker.rs:1113-1115).
  • check_http_handler only constrains param names (path param or body) and only type-checks path params for string-constructibility (context_checks.rs:3362-3396) — a body: Nope param with an undeclared Nope passes.

So on POST("/x") (body: Nope) -> Effect[HttpResult[String]] { … } (body not mentioning body) appears to certify today, and previously emitted body: Nope in the TS signature — a downstream tsc "cannot find name" the user can act on. After this change the same source ICEs in the emitter with bynk internal error (ADR 0334).

This is the same guarantee gap lower_protocol_ir's own doc comment already documents ("panicking here would make this the second ADR-0334 site in this module asserting a guarantee the checker doesn't actually give") — but the signature path resolves through the panicking helper, so this slice puts that assertion on the real emit path for every service in every program for the first time.

I could not run a build in this environment to confirm the end-to-end repro, so this is reasoned from the call chain rather than observed. Worth either (a) confirming with a fixture whose service handler names an undeclared type, and if it reproduces, giving lower_service_handler_signature_ir a non-panicking fallback (mirroring lower_protocol_ir's own Ty::Unit posture) or a real resolver diagnostic for service signatures; or (b) recording explicitly why it can't reproduce.

.collect();
emit_service(
&mut out,
s,
&protocol,
&signatures,
commons,
ctx,
Some(&smb),
);
}
CommonsItem::Agent(a) => {
smb.borrow_mut().record(out.len(), a.span);
Expand Down
52 changes: 28 additions & 24 deletions bynk-emit/src/emitter/emit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ use std::sync::Arc;
use crate::project::EmitProjectCtx;
use bynk_check::checker::{TypedCommons, Types};

use crate::ir::lower::body_writes_state;
use crate::ir::{OpSig, TypeShape};
use crate::ir::lower::{HandlerSignatureIr, body_writes_state};
use crate::ir::{OpSig, ProtocolIr, TypeShape};

use super::*;

Expand Down Expand Up @@ -1274,16 +1274,25 @@ pub(crate) fn emit_provider(
pub(crate) fn emit_service(
out: &mut String,
s: &ServiceDecl,
protocol: &ProtocolIr,
signatures: &[HandlerSignatureIr],
commons: &TypedCommons,
ctx: &EmitProjectCtx,
source_map: Option<&RefCell<SourceMapBuilder>>,
) {
let tys = commons.tys();
emit_doc_block(out, s.documentation.as_deref(), 0);
writeln!(out, "export const {name} = {{", name = s.name.name).unwrap();
let mut cron_idx = 0usize;
let mut queue_idx = 0usize;
let ws_proto = matches!(s.protocol, ServiceProtocol::WebSocket { .. });
for handler in &s.handlers {
let ws_proto = matches!(protocol, ProtocolIr::WebSocket { .. });
// #1187's slice 5: `s.handlers`/`signatures` are the same list in the
// same declaration order — `signatures` is built by mapping `s.handlers`
// 1:1 at the one call site (`emitter.rs`) — the same zip-by-index
// precedent `emit_capability`'s own `c.ops`/`ops` pairing already
// established (#1193).
for (handler, (ir_params, _ir_given, ir_ret, ir_effectful)) in s.handlers.iter().zip(signatures)
{
// v0.104/v0.106 (real-time track slice 3b): on Workers a `from websocket`
// lifecycle handler (`on open`/`on message`/`on close`) does not emit a
// service-surface method — its body runs inside the hosting Durable Object
Expand Down Expand Up @@ -1330,19 +1339,18 @@ pub(crate) fn emit_service(
// For service handlers the operation name is the handler kind
// (e.g. `call`). v0.5 has only one handler kind, so the service is a
// single-operation object literal.
let mut params: Vec<String> = handler
.params
let mut params: Vec<String> = ir_params
.iter()
.map(|p| format!("{}: {}", ts_ident(&p.name.name), ts_type_ref(&p.type_ref)))
.map(|(name, ty)| format!("{}: {}", ts_ident(name), ts_ty(*ty, tys)))
.collect();
// v0.103/v0.106: a `from websocket` lifecycle handler receives the
// `connection` as its first parameter (the synthetic binding the checker
// added — the fresh socket for `on open`, the firing socket for `on
// message`/`on close`); emit it so the lowered body's `connection` resolves.
if is_ws_handler && let ServiceProtocol::WebSocket { out_type, .. } = &s.protocol {
if is_ws_handler && let ProtocolIr::WebSocket { out_ty, .. } = protocol {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent void where the old code emitted the spelled type.

ts_type_ref(out_type) never failed — it printed whatever the user wrote. ProtocolIr::WebSocket { out_ty } is built by lower_protocol_ir with an explicit miss fallback:

out_ty: cx.resolve_type_ref(out_type).unwrap_or_else(|| cx.unit_ty()),

and that function's own doc comment states outright that this fallback exists because the checker doesn't guarantee a WebSocket frame type resolves (resolver.rs skips CommonsItem::Service; context_checks.rs:775-778 falls back to Ty::Unit rather than erroring).

On a miss this now emits connection: Connection<void> instead of connection: Connection<Frame> — silently wrong emitted output, where before the user got a name tsc could complain about. Same root cause as the panic flagged in emitter.rs: the fallback was harmless while ProtocolIr was IR-test-only, and becomes user-visible the moment the emitter reads it.

Given the fixture bless was byte-identical, this doesn't fire on any existing fixture — but a fixture that pins the miss (or a note ruling it out) would be worth having, since the failure mode is silence rather than a diagnostic.

params.insert(
0,
format!("connection: Connection<{}>", ts_type_ref(out_type)),
format!("connection: Connection<{}>", ts_ty(*out_ty, tys)),
);
}
// Events track, slice 4 (spine #936): a `via schema(N)` guard needs
Expand All @@ -1355,10 +1363,10 @@ pub(crate) fn emit_service(
// envelope-forwarding call sites (`workers.rs`, `project.rs`) widen
// their own condition to match, so the value actually arrives here.
let schema_dispatch_env_binder = if handler.kind == HandlerKind::Event
&& let ServiceProtocol::Events {
&& let ProtocolIr::Events {
schema_dispatch: Some(_),
..
} = &s.protocol
} = protocol
{
match handler.params.get(1) {
Some(env_param) => Some(ts_ident(&env_param.name.name)),
Expand Down Expand Up @@ -1452,7 +1460,7 @@ pub(crate) fn emit_service(
)
.with_source_map(Some(&body_smb));
cx.local_agents = ctx.local_agents.clone();
let async_tail = is_effectful_return(&handler.return_type);
let async_tail = *ir_effectful;
emit_block_as_function_body_with_return(
&mut body_out,
&handler.body,
Expand All @@ -1473,12 +1481,12 @@ pub(crate) fn emit_service(
// event type by `check_service_protocols`'s param-type-agreement
// check, so testing its fields here is sound.
if handler.kind == HandlerKind::Event
&& let ServiceProtocol::Events {
&& let ProtocolIr::Events {
pattern: Some(pattern),
..
} = &s.protocol
} = protocol
&& let Some(param) = handler.params.first()
&& let Some(guard) = event_pattern_guard(&ts_ident(&param.name.name), Some(pattern))
&& let Some(guard) = event_pattern_guard_ir(&ts_ident(&param.name.name), Some(pattern))
{
let prologue = format!(
"{}if (!({guard})) return undefined;\n",
Expand All @@ -1492,13 +1500,13 @@ pub(crate) fn emit_service(
// prologue technique, same three-delivery-path coverage. The
// envelope binder is either the user's own declared `env` name or
// the synthetic one inserted above.
if let ServiceProtocol::Events {
if let ProtocolIr::Events {
schema_dispatch: Some(dispatch),
..
} = &s.protocol
} = protocol
&& let Some(env_binder) = &schema_dispatch_env_binder
{
let SchemaVersionPattern::Literal(version) = &dispatch.pattern;
let SchemaVersionPattern::Literal(version) = dispatch;
let prologue = format!(
"{}if (!({env_binder}.schemaVersion === {version})) return undefined;\n",
" ".repeat(INDENT_STEP * 2)
Expand Down Expand Up @@ -1624,12 +1632,8 @@ pub(crate) fn emit_service(
};
}
params.push(format!("deps: {deps_ty}"));
let ret = ts_type_ref(&handler.return_type);
let async_kw = if is_effectful_return(&handler.return_type) {
"async "
} else {
""
};
let ret = ts_ty(*ir_ret, tys);
let async_kw = if *ir_effectful { "async " } else { "" };
writeln!(
out,
" {async_kw}{op}({params}): {ret} {{",
Expand Down
107 changes: 83 additions & 24 deletions bynk-emit/src/emitter/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::sync::Arc;

use bynk_check::checker::{NamedKind, Ty, TyId, TypedCommons};

use crate::ir::{ConstVal, EventPatternIr, EventPatternValueIr};

use super::*;

/// Lower a block to a sequence of TypeScript statements suitable for use as
Expand Down Expand Up @@ -5379,41 +5381,98 @@ fn pattern_match_tests(
}
}

/// Events track, slice 1 (spine #936): the JS boolean guard for a
/// `from Events(E { field: value, .. })` subscription filter, AND-joining one
/// test per listed field. Parallel in shape to [`pattern_match_tests`] but
/// **not** a call into it — that function's `Refined` arm panics on a
/// non-literal-kind scrutinee and its `Variant` arm tests `.tag` on the
/// scrutinee itself, neither of which fits a flat record-field test against
/// an [`EventPattern`], which is a distinct AST node precisely because an
/// event is a plain record, not a sum (see `EventPattern`'s doc comment,
/// which also records the ADR 0286 amendment this represents). No `Ty`/
/// `LowerCtx` needed: `check_event_pattern`
/// (`bynk-emit/src/project/validate.rs`) has already proven every field
/// exists and every value type-checks, so this is total and cannot panic.
/// Returns `None` for a pattern-less subscription (nothing to guard).
pub(crate) fn event_pattern_guard(path: &str, pattern: Option<&EventPattern>) -> Option<String> {
/// #1187's slice 5 (the `Service` emitter cutover): the JS boolean guard for
/// a `from Events(E { field: value, .. })` subscription filter, AND-joining
/// one test per listed field — reads [`crate::ir::EventPatternIr`] (already
/// resolved by [`crate::ir::lower::lower_protocol_ir`]) rather than the raw
/// AST `EventPattern`; [`EventPatternValueIr`]'s own doc comment already
/// named this function as its sole intended consumer, never wired up until
/// now (the AST-driven original this replaces produced byte-identical guard
/// text: `EventPatternValueIr::Const` only ever holds the same closed
/// `Int`/`Str`/`Bool` set `literal_case_label` covers, and `Variant { tag }`
/// already carries exactly the bare, unqualified tag the AST version's
/// `variant.name` destructured down to). No `Ty`/`LowerCtx` needed:
/// `check_event_pattern` (`bynk-check/src/project_model.rs`) has already
/// proven every field exists and every value type-checks, so this is total
/// and cannot panic. Returns `None` for a pattern-less subscription (nothing
/// to guard).
pub(crate) fn event_pattern_guard_ir(
path: &str,
pattern: Option<&EventPatternIr>,
) -> Option<String> {
let pattern = pattern?;
let tests: Vec<String> = pattern
.fields
.iter()
.map(|f| match &f.value {
// `payload as any` on the wire (Workers `workers_entry.rs`) or a
// real object (Bundle) either way survives a JSON round-trip
// unchanged, and a nullary variant constructs as `{ tag: "..." }`
// (`emit.rs`'s sum-constructor emission) — so `.tag` is the
// correct test on both targets.
EventPatternValue::Literal { value, .. } => {
format!("{path}.{} === {}", f.name.name, literal_case_label(value))
.map(|(name, value)| match value {
EventPatternValueIr::Const(ConstVal::Int(n)) => format!("{path}.{name} === {n}"),
EventPatternValueIr::Const(ConstVal::Str(s)) => {
format!("{path}.{name} === \"{}\"", escape_ts_string(s))
}
EventPatternValue::Variant { variant, .. } => {
format!("{path}.{}.tag === \"{}\"", f.name.name, variant.name)
EventPatternValueIr::Const(ConstVal::Bool(b)) => format!("{path}.{name} === {b}"),
EventPatternValueIr::Const(
ConstVal::Float(_) | ConstVal::DurationMillis(_) | ConstVal::Unit,
) => {
unreachable!(
"EventPatternValueIr::Const only ever holds Int/Str/Bool (its own doc \
comment) — the checker's own check_event_pattern already restricted a \
from Events(...) filter to that closed set before this pattern could exist"
)
}
EventPatternValueIr::Variant { tag } => format!("{path}.{name}.tag === \"{tag}\""),
})
.collect();
Some(tests.join(" && "))
}

#[cfg(test)]
mod event_pattern_guard_ir_tests {
use super::*;

/// Review of #1198: the byte-identity evidence against the deleted
/// AST-driven `event_pattern_guard` was entirely fixture-level. This
/// pins the guard text format directly, over a hand-built
/// `EventPatternIr` covering all four cases `EventPatternValueIr` can
/// hold (`Int`/`Str`/`Bool`/`Variant`).
#[test]
fn covers_every_value_shape() {
let pattern = EventPatternIr {
fields: vec![
(
"n".to_string(),
EventPatternValueIr::Const(ConstVal::Int(3)),
),
(
"s".to_string(),
EventPatternValueIr::Const(ConstVal::Str("a\"b".to_string())),
),
(
"b".to_string(),
EventPatternValueIr::Const(ConstVal::Bool(true)),
),
(
"status".to_string(),
EventPatternValueIr::Variant {
tag: "Active".to_string(),
},
),
],
};
assert_eq!(
event_pattern_guard_ir("e", Some(&pattern)),
Some(
"e.n === 3 && e.s === \"a\\\"b\" && e.b === true && e.status.tag === \"Active\""
.to_string()
)
);
assert_eq!(
event_pattern_guard_ir("e", None),
None,
"a pattern-less subscription has nothing to guard"
);
}
}

/// Emit `const` declarations binding the names in `pattern` from runtime `path`,
/// recursing through nested payloads (ADR 0169).
fn emit_pattern_bindings(
Expand Down
19 changes: 14 additions & 5 deletions bynk-emit/src/ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1039,11 +1039,11 @@ pub(crate) enum EventPatternValueIr {
/// A nullary sum-variant tag, resolved and unqualified — bare
/// `tag: String` mirrors `IrPat::Variant`'s own `tag`/[`GlobalRef`]'s
/// own `tag`. The AST's own optional qualifying `type_name` is
/// dropped, not lost: the sole existing consumer (the shipped
/// emitter's `event_pattern_guard`) already destructures down to the
/// bare tag alone — the qualification is disambiguation for the
/// *checker*, resolved against the field's declared sum type before
/// this point.
/// dropped, not lost: the sole consumer
/// ([`crate::emitter::lower::event_pattern_guard_ir`], #1187's slice 5)
/// already destructures down to the bare tag alone — the qualification
/// is disambiguation for the *checker*, resolved against the field's
/// declared sum type before this point.
Variant { tag: String },
}

Expand Down Expand Up @@ -1458,6 +1458,15 @@ pub(crate) struct IrHandler {
pub connection: Option<ConnectionBinder>,
pub body: IrExpr,
pub commit: CommitShape,
/// The handler's own declared return type, resolved (#1187's slice 5,
/// the `Service` emitter cutover) — mirrors [`IrItem::Fn::ret`]'s
/// identical field, added here
/// for the identical reason: [`lower::lower_handler_signature_ir`]
/// already resolved this value to compute `effectful` below and
/// discarded it, leaving a service emitter with no IR-native way to
/// render a handler's own return-type annotation without re-walking
/// `Handler::return_type` (`bynk_syntax::ast::TypeRef`) itself.
pub ret: TyId,
pub effectful: bool,
pub method_name: Option<String>,
}
Loading