Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 01888dc5de Keep large routed timelines connected when the receiver falls behind (#9109)
* fix(server): pace routed output to its receiving client

A routed transfer previously waited only for the destination-to-intermediary write, so a slower final hop overflowed the route queue. Negotiate bounded per-channel acknowledgments after final forwarding drains, preserving concurrent small sends and legacy peers.

* fix(transport): keep small routed writes concurrent

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-08 15:02:04 +00:00
27 changed files with 619 additions and 700 deletions
@@ -46,7 +46,6 @@ const LANGUAGE_ALIASES: Record<string, string> = {
"objective-c": "m",
markdown: "md",
elixir: "ex",
terraform: "tf",
};
function fenceLanguageToExtension(info: string | null | undefined): string | null {
@@ -4,7 +4,7 @@ export interface DaemonTransport {
maxFrameBytes?: number;
send: (data: string | Uint8Array | ArrayBuffer) => void | Promise<void>;
close: (code?: number, reason?: string) => void;
onMessage: (handler: (data: unknown, isBinary: boolean) => void) => () => void;
onMessage: (handler: (data: unknown, isBinary: boolean) => void | Promise<void>) => () => void;
onOpen: (handler: () => void) => () => void;
onClose: (handler: (event?: unknown) => void) => () => void;
onError: (handler: (event?: unknown) => void) => () => void;
+23 -7
View File
@@ -1923,18 +1923,34 @@ export class DaemonClient {
});
}
}
async openHostLink(channelId: string): Promise<string> {
openHostLink(channelId: string): Promise<string>;
openHostLink(
channelId: string,
options: { flowControl: true },
): Promise<{ routeId: string; flowControl: boolean }>;
async openHostLink(
channelId: string,
options?: { flowControl: true },
): Promise<string | { routeId: string; flowControl: boolean }> {
this.requireConnectedHosts();
if (this.getLastServerInfoMessage()?.features?.hostMultiplexing !== true)
throw Object.assign(new Error("Update the destination daemon"), {
code: "unsupported_capability",
});
return (
await this.sendCorrelatedSessionRequest({
message: { type: "hosts.link.open.request", channelId },
responseType: "hosts.link.open.response",
})
).routeId;
const response = await this.sendCorrelatedSessionRequest({
message: {
type: "hosts.link.open.request",
channelId,
...(options?.flowControl ? { flowControl: true } : {}),
},
responseType: "hosts.link.open.response",
});
return options?.flowControl
? { routeId: response.routeId, flowControl: response.flowControl === true }
: response.routeId;
}
acknowledgeHostLink(routeId: string, sequence: number): Promise<void> {
return this.sendSessionMessageStrict({ type: "hosts.link.ack.request", routeId, sequence });
}
sendHostFrame(routeId: string, data: string | Uint8Array): Promise<void> {
if (typeof data === "string") {
+32 -4
View File
@@ -26,11 +26,13 @@ export function createHostChannelTransport(
return () => {
const opens = new Set<() => void>();
const closes = new Set<(event?: unknown) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void>();
const messages = new Set<(data: unknown, binary: boolean) => void | Promise<void>>();
const errors = new Set<(error: unknown) => void>();
let routeId: string | null = null;
let closed = false;
const cleanups: (() => void)[] = [];
let flowControl = false;
let receivedFrames = 0;
const close = (error?: unknown) => {
if (closed) return;
closed = true;
@@ -45,10 +47,28 @@ export function createHostChannelTransport(
if (error) for (const handler of errors) handler(error);
for (const handler of closes) handler(error);
};
const deliver = (data: unknown, binary: boolean) => {
if (closed) return;
const sequence = ++receivedFrames;
try {
const deliveries = [...messages].map(async (handler) => {
await handler(data, binary);
});
void Promise.all(deliveries)
.then(async () => {
if (flowControl && !closed && routeId)
await origin.acknowledgeHostLink(routeId, sequence);
return undefined;
})
.catch(close);
} catch (error) {
close(error);
}
};
cleanups.push(
origin.subscribeRawMessages((message) => {
if (message.type === "hosts.route.frame" && message.payload.subscriptionId === routeId) {
for (const handler of messages) handler(message.payload.data, false);
deliver(message.payload.data, false);
}
if (message.type === "hosts.route.closed" && message.payload.subscriptionId === routeId)
close(
@@ -61,7 +81,7 @@ export function createHostChannelTransport(
);
cleanups.push(
origin.subscribeHostFrames((frame) => {
if (frame.routeId === routeId) for (const handler of messages) handler(frame.payload, true);
if (frame.routeId === routeId) deliver(frame.payload, true);
}),
);
cleanups.push(
@@ -71,7 +91,15 @@ export function createHostChannelTransport(
);
let opening: Promise<string>;
if (first) opening = Promise.resolve(first);
else opening = link ? origin.openHostLink(scopeId) : origin.openHostRoute(serverId, scopeId);
else if (link) {
opening = origin.openHostLink(scopeId, { flowControl: true }).then((opened) => {
// COMPAT(hostLinkFlowControl): added after v0.15.3 (#9109); remove after
// 2027-05-08 once the daemon floor supports forwarding acknowledgments.
// Older destinations omit flowControl and receive no acknowledgment messages.
flowControl = opened.flowControl;
return opened.routeId;
});
} else opening = origin.openHostRoute(serverId, scopeId);
first = null;
void opening
.then((id) => {
+1 -2
View File
@@ -5,8 +5,7 @@
"files": [
"dist",
"!dist/**/*.map",
"src/astro/LICENSE",
"src/hcl/LICENSE"
"src/astro/LICENSE"
],
"type": "module",
"main": "./dist/index.js",
@@ -3,7 +3,6 @@ import { setImmediate } from "node:timers/promises";
import { highlightCodeWithBudget, HIGHLIGHT_MAX_SOURCE_BYTES } from "../budgeted-highlighter.js";
import type { HighlightToken } from "../types.js";
import { highlightCode, highlightCodeInSteps, highlightLine } from "../highlighter.js";
import { getParserForFile } from "../parsers.js";
describe("highlightCode", () => {
it("highlights JavaScript code with correct token styles", () => {
@@ -345,137 +344,6 @@ const title = "Hello";
});
});
describe("Terraform", () => {
function styleOf(line: HighlightToken[], text: string) {
return line.find((token) => token.text === text)?.style;
}
it("highlights blocks, labels, attributes, and values", () => {
const code = [
"# The web server",
'resource "aws_instance" "web" {',
" count = 2",
" enabled = true",
' tags = merge(var.labels, { Name = "web" })',
"}",
].join("\n");
const result = highlightCode(code, "main.tf");
expect(result[0][0]).toEqual({ text: "# The web server", style: "comment" });
expect(styleOf(result[1], "resource")).toBe("type");
expect(styleOf(result[1], '"aws_instance"')).toBe("string");
expect(styleOf(result[1], '"web"')).toBe("string");
expect(styleOf(result[2], "count")).toBe("definition");
expect(styleOf(result[2], "2")).toBe("number");
expect(styleOf(result[3], "true")).toBe("literal");
expect(styleOf(result[4], "merge")).toBe("function");
expect(styleOf(result[4], "var")).toBe("variable");
expect(styleOf(result[4], "labels")).toBe("property");
});
it("highlights expressions interpolated inside strings", () => {
const result = highlightCode('name = "app-${var.env}-web"', "main.tf");
expect(result[0].map((token) => [token.text, token.style])).toEqual([
["name", "definition"],
[" ", null],
["=", "operator"],
[" ", null],
['"app-', "string"],
["${", "punctuation"],
["var", "variable"],
[".", "operator"],
["env", "property"],
["}", "punctuation"],
['-web"', "string"],
]);
});
it("highlights heredoc bodies as strings and resumes after the terminator", () => {
const code = ["policy = <<-EOT", ' { "Version": "2012" }', " EOT", "retries = 3"].join("\n");
const result = highlightCode(code, "main.tf");
expect(styleOf(result[0], "EOT")).toBe("keyword");
expect(result[1].map((token) => token.style)).toEqual(["string"]);
expect(styleOf(result[3], "retries")).toBe("definition");
expect(styleOf(result[3], "3")).toBe("number");
});
it("ends a heredoc with a Unicode delimiter", () => {
const result = highlightCode("x = <<ÉOF\nfoo\nÉOF\ny = 1", "main.tf");
expect(styleOf(result[3], "y")).toBe("definition");
expect(styleOf(result[3], "1")).toBe("number");
});
it.each([
['x = "$$${var.name}"', "$"],
['x = "%%%{ if true }"', "%"],
])("keeps escaped template text in %s as a string", (code) => {
const line = highlightCode(code, "main.tf")[0];
expect(line[line.length - 1]).toEqual({ text: code.slice(4), style: "string" });
});
it("parses a real configuration without error nodes", () => {
const code = [
"terraform {",
' required_version = ">= 1.5"',
' backend "s3" {}',
"}",
"",
'variable "subnets" {',
" type = map(object({ cidr = string }))",
" default = {}",
"}",
"",
"locals {",
" names = [for key, subnet in var.subnets : upper(key) if subnet.cidr != null]",
' first = length(local.names) > 0 ? local.names[0] : "none"',
" arns = aws_instance.web[*].arn",
" /* block comment */",
" path = provider::time::rfc3339_parse(timestamp()) // trailing",
"}",
"",
'data "aws_iam_policy_document" "assume" {',
' dynamic "statement" {',
" for_each = var.subnets",
" content {",
' sid = "${statement.key}-%{ if statement.value.cidr != "" }set%{ else }unset%{ endif }"',
" }",
" }",
"}",
"",
'output "script" {',
" value = <<EOT",
"#!/bin/sh",
"echo ${local.first}",
"EOT",
"}",
].join("\n");
const parser = getParserForFile("main.tf");
if (!parser) throw new Error("Expected a parser for .tf files");
const errors: number[] = [];
parser.parse(code).iterate({
enter(node) {
if (node.type.isError) errors.push(node.from);
},
});
expect(errors.map((from) => code.slice(from, from + 30))).toEqual([]);
});
it("uses the same grammar for tfvars and hcl files", () => {
expect(highlightCode('region = "eu-west-1"', "prod.tfvars")).toEqual(
highlightCode('region = "eu-west-1"', "terragrunt.hcl"),
);
expect(styleOf(highlightLine('region = "eu-west-1"', "prod.tfvars"), "region")).toBe(
"definition",
);
});
});
describe("highlightCodeInSteps", () => {
it("splits a large file into many bounded steps with the same result", () => {
const code = Array.from(
@@ -22,9 +22,6 @@ describe("isLanguageSupported", () => {
expect(isLanguageSupported("test.dart")).toBe(true);
expect(isLanguageSupported("test.cs")).toBe(true);
expect(isLanguageSupported("test.nix")).toBe(true);
expect(isLanguageSupported("main.tf")).toBe(true);
expect(isLanguageSupported("prod.tfvars")).toBe(true);
expect(isLanguageSupported("terragrunt.hcl")).toBe(true);
expect(isLanguageSupported("test.ex")).toBe(true);
expect(isLanguageSupported("Counter.svelte")).toBe(true);
expect(isLanguageSupported("Page.astro")).toBe(true);
@@ -68,9 +65,6 @@ describe("getSupportedExtensions", () => {
expect(extensions).toContain("dart");
expect(extensions).toContain("cs");
expect(extensions).toContain("nix");
expect(extensions).toContain("tf");
expect(extensions).toContain("tfvars");
expect(extensions).toContain("hcl");
expect(extensions).toContain("json");
expect(extensions).toContain("svelte");
expect(extensions).toContain("astro");
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (C) 2021 by Marijn Haverbeke <marijnh@gmail.com> and others
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
-41
View File
@@ -1,41 +0,0 @@
import { styleTags, tags as t } from "@lezer/highlight";
export const hclHighlighting = styleTags({
"for in if else endfor endif": t.controlKeyword,
"Attribute/Identifier": t.definition(t.propertyName),
"ObjectKey/Expression/VariableExpr/Identifier": t.definition(t.propertyName),
"ObjectKey/Expression/LiteralValue/StringLit!": t.definition(t.propertyName),
"ForIntro/Identifier TemplateForStart/Identifier": t.definition(t.variableName),
"BlockType!": t.typeName,
// Paseo themes have no role for labelName; labels render as the strings they are.
"BlockLabel!": t.string,
"FunctionNamespace/Identifier": t.namespace,
"FunctionName/Identifier": t.function(t.variableName),
"VariableExpr/Identifier": t.variableName,
"GetAttr/Identifier": t.propertyName,
Identifier: t.name,
"QuotedTemplateStart QuotedTemplateEnd TemplateLiteral": t.string,
NumericLit: t.number,
"true false": t.bool,
null: t.null,
LineComment: t.lineComment,
BlockComment: t.blockComment,
"( )": t.paren,
"{ }": t.brace,
"[ ]": t.squareBracket,
ArithOp: t.arithmeticOperator,
LogicOp: t.logicOperator,
CompareOp: t.compareOperator,
ControlOp: t.controlOperator,
"ControlOp/Colon": t.controlOperator,
AssignOp: t.definitionOperator,
DerefOp: t.derefOperator,
HeredocIdentifier: t.special(t.controlKeyword),
HeredocStart: t.operator,
"TemplateInterpolationStart TemplateInterpolationEnd": t.special(t.brace),
"TemplateDirectiveStart TemplateDirectiveEnd": t.special(t.brace),
StripMarker: t.special(t.brace),
"AttrSplat FullSplat LegacyIndex": t.derefOperator,
"Arrow Ellipsis": t.punctuation,
"ObjectElem/Colon ForIntro/Colon Comma NamespaceSeparator": t.separator,
});
-61
View File
@@ -1,61 +0,0 @@
import {
delimitedIndent,
foldInside,
foldNodeProp,
indentNodeProp,
LRLanguage,
} from "@codemirror/language";
import type { SyntaxNode } from "@lezer/common";
import { parser } from "./parser.js";
export const hclLanguage = LRLanguage.define({
name: "HCL",
parser: parser.configure({
props: [
indentNodeProp.add({
Block: delimitedIndent({ closing: "}", align: false }),
}),
foldNodeProp.add({
"Object Tuple TemplateFor TemplateIf": foldInside,
BlockComment: (tree: SyntaxNode) => {
return { from: tree.from + 2, to: tree.to - 2 };
},
HeredocTemplate(tree: SyntaxNode) {
const start = tree.getChild("HeredocIdentifier");
if (!start) {
return null;
}
return {
from: start.to,
to: tree.to,
};
},
FunctionCall(tree: SyntaxNode) {
const start = tree.getChild("(");
const end = tree.getChild(")");
if (!start || !end) {
return null;
}
return {
from: start.from + 1,
to: end.to - 1,
};
},
Block(tree: SyntaxNode) {
const start = tree.getChild("{");
const end = tree.getChild("}");
if (!start || !end) {
return null;
}
return {
from: start.from + 1,
to: end.to - 1,
};
},
}),
],
}),
languageData: {
commentTokens: { line: "#", block: { open: "/*", close: "*/" } },
},
});
-64
View File
@@ -1,64 +0,0 @@
// Generated by lezer-generator, taken from codemirror-lang-hcl@0.2.0 (MIT, see LICENSE).
// Vendored for the same reason as packages/highlight/src/nix: only Lezer primitives load here.
import { LRParser } from "@lezer/lr";
import { hclHighlighting } from "./highlight.js";
import {
scanQuotedTemplateStart,
scanQuotedTemplateEnd,
scanTemplateInterpolationStart,
scanTemplateInterpolationEnd,
scanTemplateDirectiveStart,
scanTemplateDirectiveEnd,
scanHeredocIdentifier,
scanTemplateLiteralChunk,
trackTemplate,
} from "./tokens.js";
const spec_Identifier = new Map<string, number>([
["true", 41],
["false", 43],
["null", 47],
["for", 66],
["in", 70],
["endfor", 74],
["if", 80],
["else", 84],
["endif", 88],
]);
export const parser = LRParser.deserialize({
version: 14,
states:
"GxQ]Q&jOOPeO&jOOOmQ&kO'#DcO!kQ&kO'#E_O!yQ&kO'#E^OOQO'#Ea'#EaO#UQ&jO'#ChQOQ&jOOPOOO'#Cd'#CdPOOO)C@`)C@`OOQy'#Co'#CoOOQy'#Cr'#CrO#aQ)vO'#CwOOQy'#Cm'#CmOOQ!b'#D['#D[O#oQ'|O'#DZOOQy'#Cv'#CvO$rQ&kO'#D`O$yQ&jO'#DeO%UQ&jO'#DdO&XQ&kO'#EqO&cQ&kO'#DcOOQy,59},59}O&jQ&jO,59}OOQy'#D]'#D]O(YQ'dO'#DgOOQO'#Ee'#EeO)PQ&jO'#DiO)UQ&jO'#DiOOQy'#Dp'#DpOOQP'#Dx'#DxOOQP'#Dy'#DyO%^Q&kO'#DwO*nQ'dO'#ClOOQy'#Dv'#DvOOQy'#El'#ElO%^Q&kO'#ElOOQy'#Cl'#ClO%^Q&kO,59TO#jQ)`O'#CtOOQP'#E`'#E`OOQP'#Eg'#EgO+eQ&kO,5:xO+pQ&jO,5:xOOQO-E8_-E8_O+xQ+YO'#CuO,ZQ)vO,59`O,iQ&sO'#CxO,sQ)tO'#C{O-OQ)tO'#DSOOQ%n'#Cz'#CzOOQ%n'#Eb'#EbO-ZQ)vO,59cOOQy,59c,59cO-iQ&jO'#DTO-tQ+WO,59uO.SQ&jO'#EpOOQy,59z,59zO._Q&jO,59zO%^Q&kO,5:]O.dQ&jO'#DrOOQP'#E['#E[O%^Q&kO,5:uO.iQ&kO,5:OOOQP'#Ed'#EdO/dQ&kO,5;]O/kQ&kO,5;]O/uQ&kO,5:`OOQy1G/i1G/iOOQO'#Dk'#DkOOQO,5:U,5:UO0pQ&jO'#DlOOQO-E8c-E8cO0xQ&jO,5:TO0}Q&kO,5:TOOQy,5:c,5:cOOQP'#D{'#D{OOQP'#D|'#D|OOQP'#D}'#D}OOQP'#EO'#EOOOQP'#EP'#EPOOQP'#EQ'#EQO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO,5:fO%^Q&kO'#ESOOQy'#ER'#ERO1UQ&jO'#EUOOQy'#EX'#EXOOQy'#EY'#EYOOQy'#EW'#EWOOQy,5;W,5;WO1ZQ&jO,5;WO1cQ&jO1G.oO1qQ&lO,59`OOQP-E8e-E8eO1vQ&jO1G0dOOQO1G0d1G0dO1yQ&jO1G0dO+xQ+YO'#EmOOQ%n,59a,59aOOQy1G.z1G.zOOQ%n,59d,59dO2OQ&sO,59dO2YQ&rO,59dOOQ%n,59g,59gO,sQ)tO,59gO2eQ&jO'#DQOOQ%n,59n,59nO2sQ)tO,59nO-OQ)tO,59nO3OQ&jO'#DVOOQ%n-E8`-E8`OOQy1G.}1G.}O3aQ&jO,59hO3fQ&jO,59oO%^Q&kO,59oO3nQ+WO1G/aOOQy1G/a1G/aO3|Q&kO,5;[O4TQ&jO,5;[OOQy1G/f1G/fO4]Q&jO1G/wO4hQ&jO,5:^O4pQ&jO1G0aO4xQ&kO1G/jOOQP,5;O,5;OO5|Q&kO1G0wOOQP-E8b-E8bO7eQ,UO'#DgO7oQ,UO1G/zO9RQ,UO'#ClP9]Q&jO'#DjO9bQ&kO1G/oO9iQ&jO'#DnOOQy1G/o1G/oO9wQ&jO1G/oOOQy1G0Q1G0QO;|Q'dO1G0QO<WQ'dO1G0QO>bQ'dO1G0QO>lQ'dO1G0QO@^Q'dO1G0QOAQQ&jO,5:nOOQy,5:p,5:pPOQy'#Ef'#EfPOQy,5:s,5:sPOQy,5:t,5:tOOQy1G0r1G0rOOQO7+&O7+&OOAYQ&jO7+&OOOQ%n,5;X,5;XOOQ%n1G/O1G/OOA_Q&rO1G/OOAdQ&rO1G/OOOQ%n1G/R1G/ROAoQ&jO,59lOAzQ'[O,59lOOQ%n1G/Y1G/YO2sQ)tO1G/YOBSQ&jO'#DXO2sQ)tO1G/YOBbQ&jO,59qOBpQ'[O,59qOBxQ'[O,59sOCQQ&jO1G/SOCYQ&jO1G/SO%^Q&kO1G/ZOC_Q'[O1G/ZOOQy7+${7+${OCjQ&jO,5:}OC{Q&kO1G0vOOQO-E8a-E8aOOQy7+%c7+%cODSQ&jO7+%cO%^Q&kO'#DsO%^Q&kO1G/xODXQ&jO1G/xOOQP'#E]'#E]O%^Q&kO7+%{P%^Q&kO'#EdO%^Q&kO7+%fOOQy7+%Z7+%ZOD^Q&jO7+%ZODcQ&kO,5:YODjQ&jO,5:YOOQO,5:Y,5:YOOQy1G0Y1G0YPOQy-E8d-E8dOOQO<<Ij<<IjOOQ%n7+$j7+$jODuQ&rO7+$jODzQ'[O1G/WOOQ%n1G/W1G/WOESQ'[O1G/WOOQ%n7+$t7+$tOEXQ&jO,59sO2sQ)tO7+$tOEdQ'[O1G/]OElQ'[O1G/_OOQ$Y1G/]1G/]OEtQ'[O1G/]OOQ%n1G/_1G/_OEyQ'[O1G/_O%^Q&kO7+$nOFOQ&jO7+$nOFTQ&jO7+$nOF]Q'[O7+$uOOQ$Y7+$u7+$uOFhQ'[O7+$uP%^Q&kO'#EcOOQy<<H}<<H}OFmQ&jO,5:_OFxQ&jO7+%dOGQQ&jO7+%dOOQy<<Ig<<IgOGVQ&jO<<IQOHrQ,UO1G0QOJWQ,UO1G0QOKlQ,UO1G0QOMQQ,UO1G0QOMXQ,UO1G0QOOQy<<Hu<<HuOM`Q&kO1G/tOOQO1G/t1G/tOOQ%n<<HU<<HUOOQ%n7+$r7+$rOMgQ'[O7+$rOOQ%n<<H`<<H`OOQ$Y7+$w7+$wOMlQ'[O7+$wOOQ%n7+$y7+$yOMqQ'[O7+$yOMvQ'[O<<HYONRQ&jO<<HYO%^Q&kO<<HYONWQ&jO<<HYOOQ$Y<<Ha<<HaON]Q'[O<<HaOOQP<<IO<<IOO%^Q&kO<<IOOOQyAN>lAN>lONbQ&jOAN>lONgQ&jOAN>lOOQ%n<<H^<<H^OOQ$Y<<Hc<<HcOOQ%n<<He<<HeOOQ$YAN=tAN=tONoQ'[OAN=tO%^Q&kOAN=tONtQ'[OAN=tO! PQ&jOAN=tOOQ$YAN={AN={O! UQ&jOAN>jOOQyG24WG24WO! ^Q&jOG24WOOQ$YG23`G23`O! cQ'[OG23`O! nQ'[OG23`O%^Q&kOG23`OOQPG24UG24UOOQyLD)rLD)rOOQ$YLD(zLD(zO! sQ'[OLD(zO! xQ'[OLD(zOOQ$Y!$'Lf!$'LfO!!TQ'[O!$'LfOOQ$Y!)9BQ!)9BQO!!YQ&kO'#DgO.iQ&kO'#DwO/uQ&kO'#DwO!!|Q&kO'#ClO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO.iQ&kO,5:fO/uQ&kO,5:fO!#sQ&kO1G0QO!$pQ&kO1G0QO!%mQ&kO1G0QO!(mQ&kO1G0QO!(tQ&kO1G0QO.iQ&kO7+%{O/uQ&kO7+%{O4pQ&jO1G0aO4pQ&jO1G0aO%^Q&kO,5:uO%^Q&kO,5:u",
stateData:
"!)T~O#_OSXPQYPQ~O^RO!UQO~OXWOYWO~OP[O^iOb]OdYOeYOgZO!RaO!TfO!UeO!atO#b^O#c^O#gnO#hoO~O_vOP#RX^#RX!U#RX~OPwO^xO!U{O~O^RO#[[X!T[X~OQ!VOR!POT!WO#]}O~OV!XO~OP[O^iOb]OdYOeYOgZOq!^O!RaO!UeO!atO#b^O#c^O#gnO#hoO~O!Q!ZO~P#tO#w!_O_!XX!Y!XX~O_!aO!Y!aO~OP[O^iOb]OdYOeYOgZO!RaO!UeO!atO#b^O#c^O#gnO#hoO~Or!cO!T#eX~P%^Oq!^O~PmO!T!fO~O#f!gO!R!ZX!w!ZX!y!ZX#g!ZX#i!ZX#j!ZX#k!ZX#l!ZX#m!ZX#n!ZX#o!ZX#p!ZX#q!ZX#r!ZX#s!ZX#t!ZX#u!ZX#v!ZX#w!ZXr!ZX^!ZX!T!ZX~O_!ZX!Y!ZX!a!`X!Q!ZX![!ZX#[!ZXS!ZXm!ZXx!ZX!c!ZXU!ZX~P&oO^!iO~O!a!lO~O!w!{O!y!|O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qO#s!rO#t!sO#u!}O#v#OO#w`Xr`X^`X!T`X~O!R!zO_`X!Y`X!Q`X![`X#[`XS`Xm`Xx`X!c`XU`X~P)ZOPwO^xO!U#VO~O^RO!T#WO~O#]#YOQ#aPR#aPT#aPV#aP~OQ#[OR#UXT#UX#]#UX~OS#]Om#^O~P%^OR!POT#bO#]}O~OR!POT#fO#]}O~OQ#hOR!POT!WO#]}O~Om#jOq#iOx#kO~OR!POT!WOV#mO#]}O~Or#nO#w!_O!Q#dX~O!Q#pO~O^#rO~OP[O^&yOb]OdYOeYOgZO!RaO!UeO!atO#b^O#c^O#gnO#hoO~O!T#ea~P%^Or#vO!T#ea~P%^OP[O^#xOb]OdYOeYOgZO!RaO!UeO!atO#b^O#c^O#gnO#hoO~O#f!gO!a!`X~O!a#|O~O![$OO~P%^O^$XO~O![$]O#w!_O~O#w!_O^]i#[]i!T]i~OQ#[O~O^RO!T$^O~OS$aOm$bO~P%^OS$aOm$bO#w!_O~Om$eOq#iOu$fOx#kO~OR!POT$iO#]}O~Om$kOq#iOx#kOz$lO|$mO~O^$nO~Oq$oOx$pO~OR!POT!WOV$rO#]}O~O!Q#da~P%^Or$tO!Q#da~Ox$xO!Q$vO#w!_O~Or$zOs$yO~O!Y${O#w!_O~O#w!_OP!Wi^!Wib!Wid!Wie!Wig!Wir!Wi!R!Wi!T!Wi!U!Wi!a!Wi#b!Wi#c!Wi#g!Wi#h!Wi~O!T#ei~P%^O#f!gO!R!ZX!w!ZX!y!ZX#g!ZX#i!ZX#j!ZX#k!ZX#l!ZX#m!ZX#n!ZX#o!ZX#p!ZX#q!ZX#r!ZX#s!ZX#t!ZX#u!ZX#v!ZX#w!ZX~O!a!`X!i!ZX~P6TO!i%OO#w!_O~O!w!{O!y!|O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qO#s!rO#t!sO#u!}O#v#OO#w`X~O!R!zO!i`X~P7wO#f!gO~O![%PO~P%^Or%RO!c%TO#w!_O![!bX~O![%PO~O#i!nO#j!nO#k!nO_!ni!R!ni!Y!ni!w!ni!y!ni#m!ni#n!ni#o!ni#p!ni#q!ni#r!ni#s!ni#t!ni#u!ni#v!ni#w!nir!ni!Q!ni![!ni^!ni#[!niS!nim!nix!ni!c!niU!ni!T!ni~O#g!ni#l!ni~P9|O#g!oO#l!oO~P9|O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO_!ni!R!ni!Y!ni!w!ni!y!ni#s!ni#t!ni#u!ni#v!ni#w!nir!ni!Q!ni![!ni^!ni#[!niS!nim!nix!ni!c!niU!ni!T!ni~O#q!ni#r!ni~P<bO#q!qO#r!qO~P<bO#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qO#s!rO!R!ni!w!ni!y!ni#t!ni#u!ni#v!ni#w!nir!ni^!ni!T!ni~O_!ni!Y!ni!Q!ni![!ni#[!niS!nim!nix!ni!c!niU!ni~P>vO!Q%UO#w!_O~O!T%WO~OS%XO~OS%XOm%YO#w!_O~Oq$oOu%ZOx$pO~OU%[Om%]O~Om%_Oq#iOx#kO|$mO~Oq$oOx$pOz%aO|%bO~OU%cOm%dO~OU%eOm%fO~Or%hOs%gO~O^%iO~OU%kOm%lO#w!_O~O#w!_Or#Va!Q#Va![#Va!c#Va~O!Q#di~P%^O!Q%nO~O^%qO~O![%yO~O![!ba~P%^Or%zO!c%{O![!ba~OS%|O~OU%}Om&OO~OU%}O~Oq$oOx$pO|%bO~OU&QOm&RO~OU&SOm&TO~OU&QO~OU&SO~O^&VO~Or&XOs&WO~OU&YOm&ZO#w!_O~OU&YO~O#w!_O!Q!ga!T!ga~O!Y&[O#w!_O~Os&]O~Ox$xO!T&^O!c&`O#w!_O~O#i!nO#j!nO#k!nO!R!ni!w!ni!y!ni#g!ni#l!ni#m!ni#n!ni#o!ni#p!ni#q!ni#r!ni#s!ni#t!ni#u!ni#v!ni#w!ni~O!i!ni~PGeO#g!oO#i!nO#j!nO#k!nO#l!oO!R!ni!w!ni!y!ni#m!ni#n!ni#o!ni#p!ni#q!ni#r!ni#s!ni#t!ni#u!ni#v!ni#w!ni~O!i!ni~PHyO#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO!R!ni!w!ni!y!ni#q!ni#r!ni#s!ni#t!ni#u!ni#v!ni#w!ni~O!i!ni~PJ_O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qO!R!ni!i!ni!w!ni!y!ni#t!ni#u!ni#v!ni#w!ni~O#s!ni~PKsO#s!rO~PKsO![!bi~P%^OU&aO~OU&bO~OU&cO~OU&dOm&eO#w!_O~Os&fO~O^&hO~OU&iO~O!T&kO~Ox$xO!T&kO~OU&mO~OU&mOm&oO#w!_O~Os&pO~O!Y&qO#w!_O~O!T&rO~OU&sOm&tO#w!_O~OU&sO~OU&vO~OU&vOm&wO#w!_O~OU&xO~OP!ZXb!ZXd!ZXe!ZXg!ZX!U!ZX!a!ZX#b!ZX#c!ZX#h!ZX~P&oOP`Xb`Xd`Xe`Xg`X!R`X!U`X!a`X#b`X#c`X#h`X~P)ZOP!ni^!nib!nid!nie!nig!nir!ni!T!ni!U!ni!a!ni#b!ni#c!ni#h!ni~PGeOP!ni^!nib!nid!nie!nig!nir!ni!T!ni!U!ni!a!ni#b!ni#c!ni#h!ni~PHyOP!ni^!nib!nid!nie!nig!nir!ni!T!ni!U!ni!a!ni#b!ni#c!ni#h!ni~PJ_O#g!oO#i!nO#j!nO#k!nO#l!oO#m!pO#n!pO#o!pO#p!pO#q!qO#r!qOP!ni^!nib!nid!nie!nig!nir!ni!R!ni!T!ni!U!ni!a!ni!w!ni!y!ni#b!ni#c!ni#h!ni#t!ni#u!ni#v!ni#w!ni~O#s!ni~P!&jO#s!rO~P!&jOb!w!y^X#j^~",
goto: "CW#fPPPPPPPP#gPPP#j#sPP#y%xP'YPP'YP(j*O%x*c+sP+s,R,aPPP,oP,R,uP-TP-ZP*c-i%xPP.yPP0Z1n1{P%xP%x2U3g3nP5RP%x5X6i6o5XP%x6x8Y8Y6x9n:W:m;P;`;l;u;zP;uP;u<O<O<T=O=o#s=x>O>S>[>v?Q?WP@kPPPP@qByPPCPCSRXPQVOQ#X{R$_#VXTOU{#V^bQde!c!d#v$}Q!YaQ#RtQ#SvQ#_!PQ#q!]Q#s!`Q#t!aQ#y!eS#}!l#|Q$W!zQ$c#^Q$q#kY$s#n$t%R%m%zQ%j$pQ%o$xQ%p$yU%r$|'`'aQ%s%OQ&U%gQ&g&WQ&j&]Q&n&fQ&u&pQ'b'dR'c'e#msQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'e#m]Qadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'e#l]Qadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eTxSzQ!O[f!T!Q!R!U!X#a#d#e#l$h$j%`R#Tw#m`Qadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'ei!T[!Q!R!U!X#a#d#e#l$h$j%`i!S[!Q!R!U!X#a#d#e#l$h$j%`i!Q[!Q!R!U!X#a#d#e#l$h$j%`Q#`!QR$d#ai!R[!Q!R!U!X#a#d#e#l$h$j%`Q#d!RR$j#eQ#c!RS$g#d#eS%^$h$jR&P%`#m_Qadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'e#mhQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eQVO#mhQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eSdQeS!bd!dV#u!c#v$}_cQde!c!d#v$}#ojQadekptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eZ!hi!i#x#{&y#llQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eR!kkQ$P!lR%Q#|#mmQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eQ!]aR!eeQ$w#qQ&_%sR&l&`#mrQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'e!vpQadeptv!P!]!`!c!d!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p'd'eb&z!a&z&}'P'R'T'V'X'`c&{!e&{'O'Q'S'U'W'Y'a[!tq$R$S$T$U$V[&}&|'Z'[']'^'_]'O#z%t%u%v%w%xY!uq$S$T$U$VY'P&|'[']'^'_Z'Q#z%u%v%w%xW!vq$T$U$VW'R&|']'^'_X'S#z%v%w%xU!wq$U$VU'T&|'^'_V'U#z%w%xS!xq$VS'V&|'_T'W#z%xQ!yqQ'X&|R'Y#zV#Qq#z&|T!{q#zV#Pq#z&|!quQadetv!P!]!`!a!c!d!e!l!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p'`'a'd'e!O!`b!Y#R#S#_#q#s#}$W$c$q$s%j%o%p%s&U&g&j&n&u'b'cQ'd#tR'e#yQ$|#sQ'`'bR'a'cXSOU{#VTySzUUO{#VR|UQ!U[Q#a!QQ#e!R[#g!U#a#e#l$h%`Q#l!XQ$h#dR%`$jQ#o!YS$u#o%SR%S#}Q!ddR#w!d#lkQadeptv!P!]!`!a!c!d!e!l!t!u!v!w!x!y!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p&z&{&}'O'P'Q'R'S'T'U'V'W'X'Y'`'a'd'eR!jkQzSR#Uz!hqQadetv!P!]!`!c!d!l!z#^#k#n#v#|$p$t$x$y$|$}%O%R%g%m%z&W&]&f&p'd'eU!mp&z&{S#z!e'aU$Q!t&}'OQ$R!uQ$S!vQ$T!wQ$U!xQ$V!yQ%t'QQ%u'SQ%v'UQ%w'WQ%x'YS&|!a'`Q'Z'PQ'['RQ']'TQ'^'VR'_'XQ#Z}R$`#YR![aTgQe",
nodeNames:
"⚠ QuotedTemplateStart QuotedTemplateEnd TemplateInterpolationStart TemplateInterpolationEnd TemplateDirectiveStart TemplateDirectiveEnd HeredocIdentifier Comment LineComment BlockComment ConfigFile Body Attribute Identifier AssignOp Expression LiteralValue NumericLit BoolLit true false NullLit null StringLit TemplateLiteral TemplateExpr QuotedTemplate TemplateInterpolation StripMarker TemplateDirective TemplateFor TemplateForStart for Comma in TemplateForEnd endfor TemplateIf TemplateIfIntro if TemplateElseIntro else TemplateIfEnd endif HeredocTemplate HeredocStart CollectionValue ] [ Tuple } { Object ObjectElem ObjectKey Colon VariableExpr ) FunctionCall FunctionNamespace NamespaceSeparator FunctionName ( FunctionArguments Ellipsis ForExpr ForTupleExpr ForIntro ForCond ForObjectExpr Arrow Operation UnaryOperation ArithOp LogicOp BinaryOperation ArithOp ArithOp CompareOp CompareOp LogicOp LogicOp Index NewIndex LegacyIndex GetAttr DerefOp Splat AttrSplat FullSplat Conditional ControlOp ControlOp Block BlockType BlockLabel",
maxTerm: 131,
context: trackTemplate,
nodeProps: [
["openedBy", 48, "[", 51, "{"],
["closedBy", 49, "]", 52, "}"],
],
skippedNodes: [0, 8, 9, 10],
repeatNodeCount: 7,
tokenData:
"-p~RvX^#ipq#iqr$^st$kuv%Svw%Xxy%dyz%iz{%n{|%s|}%x}!O%}!O!P&S!P!Q&z!Q!R(^!R![)l![!]*c!^!_*p!_!`+_!`!a+t!a!b,R!c!},W!}#O,l#P#Q-P#R#S,W#T#o,W#o#p-U#p#q-Z#q#r-f#r#s-k#y#z#i$f$g#i#BY#BZ#i$IS$I_#i$I|$JO#i$JT$JU#i$KV$KW#i&FU&FV#i~#nY#_~X^#ipq#i#y#z#i$f$g#i#BY#BZ#i$IS$I_#i$I|$JO#i$JT$JU#i$KV$KW#i&FU&FV#i~$cP#h~!_!`$f~$kO#r~~$pSX~OY$kZ;'S$k;'S;=`$|<%lO$k~%PP;=`<%l$k~%XO#k~~%[Pvw%_~%dO#s~~%iO!a~~%nO![~~%sO#i~~%xO#l~~%}Or~~&SO#g~~&XR!y~z{&b!O!P&g!Q![&r~&gO#u~~&jP!O!P&m~&rO!c~~&wP!w~!Q![&r~'PQ#j~z{'V!P!Q$k~'YTOz'Vz{'i{;'S'V;'S;=`(W<%lO'V~'lVOz'Vz{'i{!P'V!P!Q(R!Q;'S'V;'S;=`(W<%lO'V~(WOY~~(ZP;=`<%l'V~(cRb~!O!P(l!Q![)l#l#m)w~(oP!Q![(r~(wRb~!Q![(r!g!h)Q#X#Y)Q~)TR{|)^}!O)^!Q![)d~)aP!Q![)d~)iPb~!Q![)d~)qQb~!O!P(l!Q![)l~)zR!Q![*T!c!i*T#T#Z*T~*YRb~!Q![*T!c!i*T#T#Z*T~*hP!Y~![!]*k~*pO#f~~*uQ#o~!^!_*{!_!`+Y~+QP#b~}!O+T~+YO#c~~+_O#p~~+dQ_P!_!`+j!`!a+o~+oO#q~Q+tO!iQ~+yP#m~!_!`+|~,RO#n~~,WO#w~~,]T^~}!O,W!Q![,W!c!},W#R#S,W#T#o,W~,qP!R~z{,t~,wP#P#Q,z~-PO#v~~-UO!Q~~-ZO!U~~-^P#p#q-a~-fO#t~~-kO!T~~-pOm~",
tokenizers: [
scanQuotedTemplateStart,
scanQuotedTemplateEnd,
scanTemplateInterpolationStart,
scanTemplateInterpolationEnd,
scanTemplateDirectiveStart,
scanTemplateDirectiveEnd,
scanHeredocIdentifier,
scanTemplateLiteralChunk,
0,
1,
],
topRules: { ConfigFile: [0, 11] },
dynamicPrecedences: { "19": 1, "22": 1 },
specialized: [{ term: 14, get: (value: string) => spec_Identifier.get(value) ?? -1 }],
propSources: [hclHighlighting],
tokenPrec: 2481,
});
-10
View File
@@ -1,10 +0,0 @@
// Generated by lezer-generator, taken from codemirror-lang-hcl@0.2.0 (MIT, see LICENSE).
// Vendored for the same reason as packages/highlight/src/nix: only Lezer primitives load here.
export const QuotedTemplateStart = 1;
export const QuotedTemplateEnd = 2;
export const TemplateInterpolationStart = 3;
export const TemplateInterpolationEnd = 4;
export const TemplateDirectiveStart = 5;
export const TemplateDirectiveEnd = 6;
export const HeredocIdentifier = 7;
export const templateLiteralChunk = 105;
-317
View File
@@ -1,317 +0,0 @@
// Taken from codemirror-lang-hcl@0.2.0 (MIT, see LICENSE).
// Vendored for the same reason as packages/highlight/src/nix: only Lezer primitives load here.
import { ContextTracker, ExternalTokenizer, type InputStream } from "@lezer/lr";
import {
QuotedTemplateStart,
QuotedTemplateEnd,
TemplateInterpolationStart,
TemplateInterpolationEnd,
TemplateDirectiveStart,
TemplateDirectiveEnd,
HeredocIdentifier,
templateLiteralChunk,
} from "./terms.js";
const ContextType = {
QUOTED_TEMPLATE: 1,
TEMPLATE_INTERPOLATION: 2,
TEMPLATE_DIRECTIVE: 4,
HEREDOC_TEMPLATE: 8,
};
const hashStr = function (str: string) {
var hash = 0,
i,
chr;
if (str.length === 0) return hash;
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i);
hash = (hash << 5) - hash + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
};
class Context {
readonly parent: Context | null;
readonly type: number;
readonly heredoc_identifier: string;
readonly hash: number;
constructor(parent: Context | null, type: number, heredoc_identifier: string) {
this.parent = parent;
this.type = type;
this.heredoc_identifier = heredoc_identifier;
this.hash =
(parent ? (parent.hash + parent.hash) << 8 : 0) +
(type << 4) +
(this.heredoc_identifier === "" ? 0 : hashStr(this.heredoc_identifier));
}
}
function readTokenText(input: InputStream, end: number): string {
let text = "";
for (let offset = 0; offset < end - input.pos; offset++) {
text += String.fromCharCode(input.peek(offset));
}
return text;
}
const topCtx = new Context(null, 0, "");
export const trackTemplate = new ContextTracker({
start: topCtx,
shift(context, term, stack, input) {
if (term === QuotedTemplateStart) {
return new Context(context, ContextType.QUOTED_TEMPLATE, "");
}
if (term === TemplateInterpolationStart) {
return new Context(context, ContextType.TEMPLATE_INTERPOLATION, "");
}
if (term === TemplateDirectiveStart) {
return new Context(context, ContextType.TEMPLATE_DIRECTIVE, "");
}
if (term === HeredocIdentifier && context.type !== ContextType.HEREDOC_TEMPLATE) {
const ctx = new Context(
context,
ContextType.HEREDOC_TEMPLATE,
readTokenText(input, stack.pos),
);
return ctx;
}
if (term === HeredocIdentifier && context.type === ContextType.HEREDOC_TEMPLATE) {
return context.parent ?? context;
}
switch (term) {
case QuotedTemplateEnd:
case TemplateInterpolationEnd:
case TemplateDirectiveEnd:
return context.parent ?? context;
}
return context;
},
hash(context) {
return context.hash;
},
});
const HEX_DIGIT = /[0-9a-fA-F]/;
function scanEscapeSequence(input: InputStream): void {
input.advance();
const kind = String.fromCharCode(input.next);
if (kind === "u" || kind === "U") {
const digits = kind === "u" ? 4 : 8;
for (let i = 0; i < digits; i++) {
input.advance();
if (!HEX_DIGIT.test(String.fromCharCode(input.next))) return;
}
} else if (!'"nrt\\'.includes(kind)) {
return;
}
input.advance();
input.acceptToken(templateLiteralChunk);
}
function scanHeredocBody(input: InputStream, expected: string): void {
let has_leading_whitespace_with_newline = false;
let has_leading_whitespace = false;
let next = String.fromCharCode(input.next);
while (/\s/.test(next)) {
has_leading_whitespace = true;
if (next === "\n") {
has_leading_whitespace_with_newline = true;
}
input.advance();
next = String.fromCharCode(input.next);
}
if (has_leading_whitespace) {
input.acceptToken(templateLiteralChunk);
}
if (!has_leading_whitespace_with_newline) {
return;
}
let advance = 0;
for (let i = 0; i < expected.length; i++) {
if (String.fromCharCode(input.next) === expected[i]) {
input.advance();
advance++;
} else {
if (advance) {
input.acceptToken(templateLiteralChunk);
}
return;
}
}
// check if the identifier is on a line of its own
let i = 0;
while (true) {
const ch = input.peek(i);
if (ch == -1 || String.fromCharCode(ch) === "\n") {
return input.acceptToken(HeredocIdentifier);
}
if (!/\s/.test(String.fromCharCode(ch))) {
return input.acceptToken(templateLiteralChunk);
}
i++;
}
}
// `$${` and `%%{` are escapes. The doubled marker counts only when `{` follows it, so in `$$${`
// the first `$` is literal text and the rest is the escape.
function scanTemplateMarker(input: InputStream, marker: string, startToken: number): void {
input.advance();
if (String.fromCharCode(input.next) === "{") {
input.advance();
return input.acceptToken(startToken);
}
if (String.fromCharCode(input.next) === marker && String.fromCharCode(input.peek(1)) === "{") {
input.advance();
input.advance();
}
input.acceptToken(templateLiteralChunk);
}
export const scanQuotedTemplateStart = new ExternalTokenizer(
(input, stack) => {
if (
stack.canShift(QuotedTemplateStart) &&
!(stack.context && stack.context.type === ContextType.QUOTED_TEMPLATE) &&
String.fromCharCode(input.next) === '"'
) {
input.advance();
return input.acceptToken(QuotedTemplateStart);
}
},
{ contextual: true, fallback: true },
);
export const scanQuotedTemplateEnd = new ExternalTokenizer(
(input, stack) => {
if (
stack.canShift(QuotedTemplateEnd) &&
stack.context &&
stack.context.type === ContextType.QUOTED_TEMPLATE &&
String.fromCharCode(input.next) === '"'
) {
input.advance();
return input.acceptToken(QuotedTemplateEnd);
}
},
{ contextual: true, fallback: true },
);
export const scanTemplateLiteralChunk = new ExternalTokenizer(
(input, stack) => {
if (
stack.canShift(templateLiteralChunk) &&
stack.context &&
stack.context.type === ContextType.QUOTED_TEMPLATE
) {
if (String.fromCharCode(input.next) === "\\") {
return scanEscapeSequence(input);
}
}
if (
stack.canShift(templateLiteralChunk) &&
stack.context &&
(stack.context.type === ContextType.QUOTED_TEMPLATE ||
stack.context.type === ContextType.HEREDOC_TEMPLATE) &&
input.next !== -1
) {
input.advance();
return input.acceptToken(templateLiteralChunk);
}
},
{ contextual: true, fallback: true },
);
export const scanTemplateInterpolationStart = new ExternalTokenizer(
(input, stack) => {
if (
stack.canShift(TemplateInterpolationStart) &&
stack.canShift(templateLiteralChunk) &&
!(stack.context && stack.context.type === ContextType.TEMPLATE_INTERPOLATION) &&
String.fromCharCode(input.next) === "$"
) {
return scanTemplateMarker(input, "$", TemplateInterpolationStart);
}
},
{ contextual: true, fallback: true },
);
export const scanTemplateInterpolationEnd = new ExternalTokenizer(
(input, stack) => {
if (
stack.canShift(TemplateInterpolationEnd) &&
stack.context &&
stack.context.type === ContextType.TEMPLATE_INTERPOLATION &&
String.fromCharCode(input.next) === "}"
) {
input.advance();
return input.acceptToken(TemplateInterpolationEnd);
}
},
{ contextual: true, fallback: true },
);
export const scanTemplateDirectiveStart = new ExternalTokenizer(
(input, stack) => {
if (
stack.canShift(TemplateDirectiveStart) &&
stack.canShift(templateLiteralChunk) &&
!(stack.context && stack.context.type === ContextType.TEMPLATE_DIRECTIVE) &&
String.fromCharCode(input.next) === "%"
) {
return scanTemplateMarker(input, "%", TemplateDirectiveStart);
}
},
{ contextual: true, fallback: true },
);
export const scanTemplateDirectiveEnd = new ExternalTokenizer(
(input, stack) => {
if (
stack.canShift(TemplateDirectiveEnd) &&
stack.context &&
stack.context.type === ContextType.TEMPLATE_DIRECTIVE &&
String.fromCharCode(input.next) === "}"
) {
input.advance();
return input.acceptToken(TemplateDirectiveEnd);
}
},
{ contextual: true, fallback: true },
);
export const scanHeredocIdentifier = new ExternalTokenizer(
(input, stack) => {
if (
stack.canShift(HeredocIdentifier) &&
!(stack.context && stack.context.type === ContextType.HEREDOC_TEMPLATE) &&
input.next !== -1
) {
let next = String.fromCharCode(input.next);
while (/[\p{L}\p{N}_-]/u.test(next)) {
input.advance();
next = String.fromCharCode(input.next);
}
return input.acceptToken(HeredocIdentifier);
}
if (
stack.canShift(HeredocIdentifier) &&
stack.canShift(templateLiteralChunk) &&
stack.context &&
stack.context.type === ContextType.HEREDOC_TEMPLATE &&
input.next != -1
) {
return scanHeredocBody(input, stack.context.heredoc_identifier);
}
},
{ contextual: true, fallback: true },
);
-5
View File
@@ -20,7 +20,6 @@ import { csharpLanguage } from "./csharp/language.js";
import { astroParser } from "./astro/parser.js";
import { vueParser } from "./vue/parser.js";
import { nixLanguage } from "./nix/language.js";
import { hclLanguage } from "./hcl/language.js";
import { parser as svelteBaseParser } from "./svelte/parser.js";
import { configureNesting, defaultNesting } from "./svelte/nesting.js";
@@ -83,10 +82,6 @@ const languagesByExtension: Record<string, Language> = {
cs: csharpLanguage,
// Nix
nix: nixLanguage,
// Terraform / HCL
tf: hclLanguage,
tfvars: hclLanguage,
hcl: hclLanguage,
// Elixir
ex: language(elixirParser),
exs: language(elixirParser),
@@ -390,3 +390,65 @@ it("unpaced transports retain ordinary outbound admission and accept inbound chu
expect(delivered).toEqual(["A"]);
await transport.send("small");
});
it("fills an ordered writer's window while keeping chunk sequences contiguous", async () => {
const delivered: (string | Uint8Array)[] = [];
const receiver = new ChunkedTransport({
frameBytes: 8,
send() {},
deliver: (frame) => {
delivered.push(frame);
},
});
receiver.enabled = true;
const drain = Promise.withResolvers<void>();
const frames: (string | Uint8Array)[] = [];
const sender = new ChunkedTransport({
frameBytes: 8,
supportsConcurrentWrites: true,
send: (frame) => {
frames.push(frame);
receiver.receive(frame);
return drain.promise;
},
deliver() {},
});
sender.enabled = true;
const first = sender.send("one");
const second = sender.send("two");
expect(delivered).toEqual(["one", "two"]);
expect(sender.queuedByteLength).toBe(0);
const large = sender.send("large message");
const after = sender.send("after");
expect(frames).toHaveLength(3);
expect(sender.queuedByteLength).toBe(5);
drain.resolve();
await Promise.all([first, second, large, after]);
expect(delivered).toEqual(["one", "two", "large message", "after"]);
expect(sender.queuedByteLength).toBe(0);
});
it("accounts for independently draining writes and a rejected admission", async () => {
const firstDrain = Promise.withResolvers<void>();
const secondDrain = Promise.withResolvers<void>();
const sender = new ChunkedTransport({
frameBytes: 8,
supportsConcurrentWrites: true,
send: (frame) => {
if (frame === "reject") throw new Error("capacity");
return frame === "one" ? firstDrain.promise : secondDrain.promise;
},
deliver() {},
});
const first = sender.send("one");
const second = sender.send("two");
expect(() => sender.send("reject")).toThrow("capacity");
expect(sender.queuedByteLength).toBe(0);
secondDrain.resolve();
await second;
expect(sender.queuedByteLength).toBe(0);
firstDrain.resolve();
await first;
expect(sender.queuedByteLength).toBe(0);
await sender.send("usable");
});
+21 -13
View File
@@ -23,6 +23,8 @@ export class ChunkedTransport {
private readonly io: {
frameBytes: number;
supportsDrain?: boolean;
/** Writes are admitted synchronously in order; promises track only their drain. */
supportsConcurrentWrites?: boolean;
send: (frame: Frame, waitForDrain: boolean) => void | Promise<void>;
deliver: (frame: Frame) => void;
onActivity?: () => void;
@@ -46,9 +48,11 @@ export class ChunkedTransport {
const chunk =
this.enabled && this.io.supportsDrain !== false && byteLength > this.io.frameBytes;
if (this.closed) throw new Error("Transport closed");
let started = false;
const write = () => {
if (this.closed) throw new Error("Transport closed");
this.activeBytes = byteLength;
started = true;
this.activeBytes += byteLength;
// A synchronous rejection did not admit a frame. Only accepted writes can
// fail the connection; queued admission failures still belong to their sender.
const result = chunk
@@ -76,27 +80,31 @@ export class ChunkedTransport {
result = this.pending ? this.pending.then(write) : write();
} catch (error) {
this.queuedBytes -= byteLength;
this.activeBytes = 0;
if (started) this.activeBytes -= byteLength;
throw error;
}
if (!result) {
this.queuedBytes -= byteLength;
this.activeBytes = 0;
if (started) this.activeBytes -= byteLength;
return Promise.resolve();
}
const completion = Promise.resolve(result).finally(() => {
this.queuedBytes -= byteLength;
this.activeBytes = 0;
});
const tail = completion.then(
() => undefined,
() => undefined,
);
this.pending = tail;
void tail.then(() => {
if (this.pending === tail) this.pending = undefined;
return undefined;
if (started) this.activeBytes -= byteLength;
});
// Ordinary writes already entered an ordered writer and can use its bounded
// window. Chunk sequences remain a barrier so their frames never interleave.
if (chunk || !this.io.supportsConcurrentWrites) {
const tail = completion.then(
() => undefined,
() => undefined,
);
this.pending = tail;
void tail.then(() => {
if (this.pending === tail) this.pending = undefined;
return undefined;
});
}
return completion;
}
+12 -1
View File
@@ -116,8 +116,19 @@ export const HostsLinkOpenRequestSchema = z.object({
type: z.literal("hosts.link.open.request"),
requestId: z.string(),
channelId: z.string().min(1).max(256),
flowControl: z.boolean().optional(),
});
export const HostsLinkOpenResponseSchema = z.object({
type: z.literal("hosts.link.open.response"),
payload: z.object({ requestId: z.string(), routeId: z.string() }),
payload: z.object({
requestId: z.string(),
routeId: z.string(),
flowControl: z.boolean().optional(),
}),
});
export const HostsLinkAckSchema = z.object({
type: z.literal("hosts.link.ack.request"),
routeId: z.string(),
sequence: z.number().int().positive(),
});
+2
View File
@@ -5,6 +5,7 @@ import {
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsLinkOpenRequestSchema,
HostsLinkAckSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,
@@ -3397,6 +3398,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
HostsUpdateRequestSchema,
HostsRemoveRequestSchema,
HostsLinkOpenRequestSchema,
HostsLinkAckSchema,
HostsRouteOpenRequestSchema,
HostsRouteFrameRequestSchema,
HostsRouteCloseRequestSchema,
@@ -11,6 +11,7 @@ const INBOUND_PERMISSION = {
"hosts.update.request": "tunnel.manage",
"hosts.remove.request": "tunnel.manage",
"hosts.link.open.request": null,
"hosts.link.ack.request": null,
"hosts.route.open.request": null,
"hosts.route.frame.request": null,
"hosts.route.close.request": null,
@@ -0,0 +1,51 @@
export class HostChannelOutputLimitError extends Error {
constructor() {
super("Host channel output limit reached");
}
}
/** Completion belongs to the receiver's forwarding write, not just our physical socket. */
export class HostChannelFlowControl {
private sequence = 0;
private bytes = 0;
private closed = false;
private readonly pending = new Map<
number,
{
resolve(): void;
reject(error: Error): void;
}
>();
constructor(private readonly reserveShared: (bytes: number) => () => void) {}
async send(bytes: number, write: () => Promise<void>): Promise<void> {
if (this.closed) throw new Error("Host channel closed");
// Bound both payload retention and metadata for a producer of tiny frames.
if (this.bytes + bytes > 32 * 1024 * 1024 || this.pending.size >= 4096)
throw new HostChannelOutputLimitError();
const release = this.reserveShared(bytes);
this.bytes += bytes;
const sequence = ++this.sequence;
const acknowledgment = new Promise<void>((resolve, reject) => {
this.pending.set(sequence, { resolve, reject });
});
try {
await Promise.all([acknowledgment, write()]);
} finally {
this.pending.delete(sequence);
this.bytes -= bytes;
release();
}
}
acknowledge(sequence: number): void {
this.pending.get(sequence)?.resolve();
}
close(): void {
this.closed = true;
for (const pending of this.pending.values()) pending.reject(new Error("Host channel closed"));
this.pending.clear();
}
}
@@ -1141,7 +1141,7 @@ test("source-key registration remains idempotent at directory capacity", async (
});
/** Real WebSocket proxy records the A–B physical wire without replacing either daemon. */
async function hostWire(b: TestPaseoDaemon) {
async function hostWire(b: TestPaseoDaemon, options: { legacyFlowControl?: boolean } = {}) {
const http = createServer();
const wss = new WebSocketServer({ server: http });
const frames: { binary: boolean; bytes: Buffer; direction: "toB" | "fromB" }[] = [];
@@ -1154,7 +1154,14 @@ async function hostWire(b: TestPaseoDaemon) {
for (const send of pending.splice(0)) send();
});
downstream.on("message", (data, binary) => {
const bytes = Buffer.from(data as Buffer);
let bytes = Buffer.from(data as Buffer);
if (options.legacyFlowControl && !binary) {
const envelope = JSON.parse(bytes.toString());
if (envelope.message?.type === "hosts.link.open.request") {
delete envelope.message.flowControl;
bytes = Buffer.from(JSON.stringify(envelope));
}
}
frames.push({ binary, bytes, direction: "toB" });
const send = () => {
if (upstream.readyState === 1) upstream.send(bytes, { binary });
@@ -2390,6 +2397,169 @@ test(
},
);
test("a host channel waits for its downstream consumer without blocking sibling requests", async () => {
const b = await daemon();
const destination = await api(b);
const workspace = await destination.paseo.workspaces.create({
source: { kind: "directory", path: b.staticDir },
});
const agent = await workspace.agents.create({
config: { provider: "claude/claude-test", modeId: "full-access" },
title: "Flow-controlled history",
});
const text = "x".repeat(240 * 1024);
for (let index = 0; index < 50; index++)
await b.daemon.agentManager.appendTimelineItem(agent.id, { type: "user_message", text });
const channel = createHostChannelTransport(
destination.client,
b.daemon.getServerId()!,
"held-consumer",
null,
true,
)({ url: "ws://host-link/ws" });
const received = Promise.withResolvers<void>();
const forward = Promise.withResolvers<void>();
let binaryFrames = 0;
channel.onMessage((_data, binary) => {
if (!binary) return;
binaryFrames++;
received.resolve();
return forward.promise;
});
const consumer = new DaemonClient({
url: "ws://host-link/ws",
clientId: "held-consumer",
reconnect: { enabled: false },
transportFactory: () => channel,
});
const remote = createPaseoApi(consumer);
cleanup.push(async () => {
forward.resolve();
await remote.dispose();
await consumer.close();
});
await consumer.connect();
const history = remote.agents.ref(agent.id).timeline.refetch({ limit: 0 });
void history.catch(() => {}); // Cleanup can close the request after an earlier assertion fails.
await received.promise;
// A round trip on the same physical link must progress while this channel is held.
await destination.client.ping();
expect(binaryFrames).toBe(1);
forward.resolve();
const response = await history;
const messages = response.entries.flatMap((row) =>
row.item.type === "user_message" ? [row.item.text] : [],
);
expect(messages).toHaveLength(50);
expect(messages.every((message) => message === text)).toBe(true);
await consumer.ping();
});
test("small host-channel responses fill the window before earlier forwarding completes", async () => {
const b = await daemon();
const destination = await api(b);
const channel = createHostChannelTransport(
destination.client,
b.daemon.getServerId()!,
"concurrent-consumer",
null,
true,
)({ url: "ws://host-link/ws" });
const first = Promise.withResolvers<void>();
const forward = Promise.withResolvers<void>();
let responses = 0;
channel.onMessage((data, binary) => {
if (binary || JSON.parse(String(data)).message?.type !== "pong") return;
responses++;
first.resolve();
return forward.promise;
});
const consumer = new DaemonClient({
url: "ws://host-link/ws",
clientId: "concurrent-consumer",
reconnect: { enabled: false },
transportFactory: () => channel,
});
cleanup.push(async () => {
forward.resolve();
await consumer.close();
});
await consumer.connect();
const requests = Promise.all(Array.from({ length: 10 }, () => consumer.ping()));
void requests.catch(() => {});
await first.promise;
await destination.client.ping();
await destination.client.ping();
expect(responses).toBe(10);
forward.resolve();
await requests;
});
test("legacy host links receive no acknowledgment messages", async () => {
const b = await daemon();
const wire = await hostWire(b, { legacyFlowControl: true });
const origin = createHostClient(wire.connection, {
clientId: "legacy-flow-origin",
reconnect: { enabled: false },
});
cleanup.push(() => origin.close());
await origin.connect();
const consumer = new DaemonClient({
url: "ws://host-link/ws",
clientId: "legacy-flow-consumer",
reconnect: { enabled: false },
transportFactory: createHostChannelTransport(
origin,
b.daemon.getServerId()!,
"legacy",
null,
true,
),
});
cleanup.push(() => consumer.close());
await consumer.connect();
await consumer.ping();
await origin.ping();
const operations = wire.frames
.filter((frame) => !frame.binary && frame.direction === "toB")
.map((frame) => JSON.parse(frame.bytes.toString()).message?.type);
expect(operations).toContain("hosts.link.open.request");
expect(operations).not.toContain("hosts.link.ack.request");
});
test("mixed asynchronous and synchronous receiver failures close only their host channel", async () => {
const b = await daemon();
const destination = await api(b);
const channel = createHostChannelTransport(
destination.client,
b.daemon.getServerId()!,
"failing-consumer",
null,
true,
)({ url: "ws://host-link/ws" });
const failed = Promise.withResolvers<unknown>();
channel.onError(failed.resolve);
channel.onMessage(async () => {
throw new Error("asynchronous receiver failure");
});
channel.onMessage(() => {
throw new Error("synchronous receiver failure");
});
const consumer = new DaemonClient({
url: "ws://host-link/ws",
clientId: "failing-consumer",
reconnect: { enabled: false },
transportFactory: () => channel,
});
cleanup.push(() => consumer.close());
const connecting = consumer.connect().catch((error: unknown) => error);
expect(await failed.promise).toBeInstanceOf(Error);
await connecting;
expect(consumer.isConnected).toBe(false);
await destination.client.ping();
});
test("a pre-write host budget rejection leaves sibling routes connected", async () => {
const { HostTransportBudget } = await import("@getpaseo/client/node");
const b = await daemon();
@@ -384,7 +384,7 @@ export class ConnectedHosts {
originSessionId: string;
permissions: readonly DaemonPermission[];
signal: AbortSignal;
frame(data: string | Uint8Array): void;
frame(data: string | Uint8Array): void | Promise<void>;
ended(code: string, message: string): void;
}): Promise<{ send(data: string | Uint8Array): void; close(): void }> {
await this.ready;
@@ -499,7 +499,7 @@ export class ConnectedHosts {
return;
}
}
input.frame(binary ? (data as Uint8Array) : String(data));
return input.frame(binary ? (data as Uint8Array) : String(data));
});
await ready;
if (closed) throw new HostError("host_offline", "Host closed during connection");
@@ -0,0 +1,118 @@
import { afterEach, expect, test } from "vitest";
import { DaemonLinkEndpoint, type VirtualHostSocket } from "./index.js";
import { SessionDelivery } from "../session/owned-subscriptions/index.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
const cleanup: (() => Promise<void>)[] = [];
afterEach(async () => {
for (const close of cleanup.splice(0).toReversed()) await close();
});
async function link() {
const source = {};
const messages: SessionOutboundMessage[] = [];
const sockets: VirtualHostSocket[] = [];
const delivery = new SessionDelivery((_source, message) => {
messages.push(message);
});
delivery.attach(source, true);
const endpoint = new DaemonLinkEndpoint({
identity: "physical",
permissions: () => ["daemon.read"],
routed: () => false,
bufferedAmount: () => 0,
delivery,
admit: async (socket) => {
sockets.push(socket);
},
});
cleanup.push(async () => {
endpoint.close();
await delivery.close();
});
const dispatch = (message: SessionInboundMessage) =>
delivery.request(source, message, () => endpoint.dispatch(message));
const open = async (channelId: string, flowControl = true) => {
await dispatch({
type: "hosts.link.open.request",
requestId: channelId,
channelId,
flowControl,
});
const response = messages.find(
(message) =>
message.type === "hosts.link.open.response" && message.payload.requestId === channelId,
);
expect(response).toMatchObject({
type: "hosts.link.open.response",
payload: flowControl ? { flowControl: true } : {},
});
if (response?.type !== "hosts.link.open.response") throw new Error("Missing opened channel");
return { socket: sockets.at(-1)!, id: response.payload.routeId };
};
const ack = (routeId: string, sequence: number) =>
dispatch({ type: "hosts.link.ack.request", routeId, sequence });
const frames = () =>
messages
.filter((message) => message.type === "hosts.route.frame")
.map((message) => message.payload.data);
return { open, ack, frames };
}
test("a channel waits for forwarding acknowledgments while its sibling keeps sending", async () => {
const host = await link();
const slow = await host.open("slow");
const sibling = await host.open("sibling");
let completed = false;
const first = slow.socket.send("first").then(() => {
completed = true;
return undefined;
});
let secondCompleted = false;
const second = slow.socket.send("second").then(() => {
secondCompleted = true;
return undefined;
});
await expect.poll(host.frames).toEqual(["first", "second"]);
expect(completed).toBe(false);
const independent = sibling.socket.send("independent");
await expect.poll(host.frames).toEqual(["first", "second", "independent"]);
await host.ack(sibling.id, 1);
await independent;
await host.ack(slow.id, 1);
await first;
await expect.poll(host.frames).toEqual(["first", "second", "independent"]);
await host.ack(slow.id, 1); // A duplicate acknowledgment cannot release the next frame.
expect(completed).toBe(true);
expect(secondCompleted).toBe(false);
await host.ack(slow.id, 2);
await second;
});
test("closing a blocked channel rejects its pending writes and leaves a sibling usable", async () => {
const host = await link();
const slow = await host.open("slow");
const sibling = await host.open("sibling", false);
const first = slow.socket.send("first");
const second = slow.socket.send("second");
const results = Promise.allSettled([first, second]);
await expect.poll(host.frames).toEqual(["first", "second"]);
slow.socket.close();
expect((await results).map((result) => result.status)).toEqual(["rejected", "rejected"]);
await sibling.socket.send("legacy sibling");
expect(host.frames()).toEqual(["first", "second", "legacy sibling"]);
});
test("an unpaced producer exhausting its acknowledgment window closes only its channel", async () => {
const host = await link();
const slow = await host.open("slow");
const sibling = await host.open("sibling", false);
const writes: Promise<unknown>[] = [];
// A producer ignoring send completion must remain bounded even for tiny messages.
for (let index = 0; index < 5000; index++)
writes.push(slow.socket.send("x").catch((error: unknown) => error));
await Promise.all(writes);
expect(slow.socket.readyState).toBe(3);
await sibling.socket.send("still connected");
expect(host.frames().at(-1)).toBe("still connected");
});
@@ -1,3 +1,4 @@
import { HostChannelFlowControl, HostChannelOutputLimitError } from "./flow-control.js";
import type { SessionDelivery } from "../session/owned-subscriptions/index.js";
import { EventEmitter } from "node:events";
import { createHash } from "node:crypto";
@@ -15,6 +16,7 @@ import type {
/** A logical socket has no network resources or authority of its own. */
export class VirtualHostSocket extends EventEmitter {
readonly maxFrameBytes = MAX_HOST_CHANNEL_FRAME_BYTES;
readonly supportsConcurrentWrites = true;
readyState = 1;
constructor(
private readonly output: (data: string | Uint8Array) => Promise<void>,
@@ -61,7 +63,11 @@ export class VirtualHostSocket extends EventEmitter {
/** One authenticated physical source owns all its channels and their admission ceiling. */
export class DaemonLinkEndpoint {
private readonly channels = new Map<string, { scopeId: string; socket: VirtualHostSocket }>();
private readonly channels = new Map<
string,
{ scopeId: string; socket: VirtualHostSocket; flowControl?: HostChannelFlowControl }
>();
private pendingOutputBytes = 0;
private closed = false;
constructor(
private readonly parent: {
@@ -76,6 +82,7 @@ export class DaemonLinkEndpoint {
handles(message: SessionInboundMessage): boolean {
return (
message.type === "hosts.link.open.request" ||
message.type === "hosts.link.ack.request" ||
(message.type === "hosts.route.frame.request" && this.channels.has(message.routeId)) ||
(message.type === "hosts.route.close.request" &&
((!!message.routeId && this.channels.has(message.routeId)) ||
@@ -83,6 +90,10 @@ export class DaemonLinkEndpoint {
);
}
async dispatch(message: SessionInboundMessage): Promise<void> {
if (message.type === "hosts.link.ack.request") {
this.channels.get(message.routeId)?.flowControl?.acknowledge(message.sequence);
return;
}
if (message.type === "hosts.link.open.request") {
const error = (code: string, text: string) =>
this.parent.delivery.reply({
@@ -103,8 +114,19 @@ export class DaemonLinkEndpoint {
return;
}
let socket: VirtualHostSocket;
const flowControl = message.flowControl
? new HostChannelFlowControl((bytes) => {
if (this.pendingOutputBytes + bytes > 256 * 1024 * 1024)
throw new HostChannelOutputLimitError();
this.pendingOutputBytes += bytes;
return () => {
this.pendingOutputBytes -= bytes;
};
})
: undefined;
const owner = this.parent.delivery.begin("host-link", undefined, () => {
this.channels.delete(owner.id);
flowControl?.close();
socket?.close();
});
const id = owner.id;
@@ -130,15 +152,25 @@ export class DaemonLinkEndpoint {
throw new Error("Host channel output limit reached");
}
try {
if (output) await owner.emitAndWait(output);
else await owner.emitBinaryAndWait(frame!);
const write = async () => {
if (output) await owner.emitAndWait(output);
else await owner.emitBinaryAndWait(frame!);
};
if (flowControl) await flowControl.send(bytes, write);
else await write();
} catch (failure) {
socket.close();
socket.close(
failure instanceof HostChannelOutputLimitError ? 1009 : 1000,
failure instanceof HostChannelOutputLimitError
? failure.message
: "Host channel closed",
);
throw failure;
}
},
(code, reason) => {
this.channels.delete(id);
flowControl?.close();
let failure = "host_offline";
if (code === 1009) failure = "resource_limit";
if (code === 1008) failure = "permission_denied";
@@ -167,7 +199,7 @@ export class DaemonLinkEndpoint {
});
},
);
this.channels.set(id, { scopeId: message.channelId, socket });
this.channels.set(id, { scopeId: message.channelId, socket, flowControl });
try {
await this.parent.admit(socket, ceiling);
if (this.closed || socket.readyState !== 1) {
@@ -176,7 +208,11 @@ export class DaemonLinkEndpoint {
}
this.parent.delivery.reply({
type: "hosts.link.open.response",
payload: { requestId: message.requestId, routeId: id },
payload: {
requestId: message.requestId,
routeId: id,
...(flowControl ? { flowControl: true } : {}),
},
});
} catch {
socket.close();
@@ -0,0 +1,70 @@
import { expect, test } from "vitest";
import { ConnectedHostSession, type ConnectedHosts } from "./index.js";
import { SessionDelivery } from "../session/owned-subscriptions/index.js";
// The upstream host and final writer are independently controlled transport peers.
test.each(["text", "binary"] as const)(
"routed %s completion waits for the final writer",
async (kind) => {
const source = {};
const writing = Promise.withResolvers<void>();
const drain = Promise.withResolvers<void>();
const write = () => {
writing.resolve();
return drain.promise;
};
const delivery = new SessionDelivery((_source, message) => {
if (message.type === "hosts.route.frame") return write();
}, write);
delivery.attach(source, true);
let receive!: Parameters<ConnectedHosts["openRoute"]>[0]["frame"];
const unexpected = (): never => {
throw new Error("Unexpected host operation");
};
const session = new ConnectedHostSession(
{
list: unexpected,
observe: unexpected,
add: unexpected,
update: unexpected,
remove: unexpected,
openRoute: async (input) => {
receive = input.frame;
return { send: () => {}, close: () => {} };
},
},
delivery,
{ sourceId: "owner", manageSources: false },
() => [],
() => {},
() => false,
"origin",
() => 0,
);
const request = {
type: "hosts.route.open.request" as const,
requestId: "open",
serverId: "remote",
scopeId: "scope",
};
await delivery.request(source, request, () => session.dispatch(request)!);
let completed = false;
const forwarded = Promise.resolve(
receive(kind === "text" ? "payload" : new Uint8Array([1, 2])),
).then(() => {
completed = true;
return undefined;
});
try {
await writing.promise;
expect(completed).toBe(false);
drain.resolve();
await forwarded;
expect(completed).toBe(true);
} finally {
drain.resolve();
await forwarded;
await delivery.close();
}
},
);
@@ -23,7 +23,10 @@ interface SessionRoute {
export class ConnectedHostSession {
private readonly routes = new Map<string, SessionRoute>();
constructor(
private readonly hosts: ConnectedHosts,
private readonly hosts: Pick<
ConnectedHosts,
"list" | "observe" | "add" | "update" | "remove" | "openRoute"
>,
private readonly delivery: SessionDelivery,
private readonly actor: HostActor,
private readonly permissions: () => DaemonPermission[],
@@ -206,7 +209,7 @@ export class ConnectedHostSession {
originSessionId: this.originSessionId,
permissions: this.permissions(),
signal: owner.signal,
frame: (data) => {
frame: async (data) => {
const output: SessionOutboundMessage | null =
typeof data === "string"
? {
@@ -223,8 +226,8 @@ export class ConnectedHostSession {
fail("resource_limit", "The receiving client cannot keep up with host output");
return;
}
if (output) owner.emit(output);
else owner.emitBinary(binary!);
if (output) await owner.emitAndWait(output);
else await owner.emitBinaryAndWait(binary!);
},
ended: fail,
});
@@ -451,6 +451,7 @@ function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffe
export interface WebSocketLike {
maxFrameBytes?: number;
supportsConcurrentWrites?: boolean;
readyState: number;
bufferedAmount?: number;
send: (
@@ -1307,6 +1308,7 @@ export class VoiceAssistantWebSocketServer {
if (!transport) {
transport = new ChunkedTransport({
frameBytes: ws.maxFrameBytes ?? 32 * 1024 * 1024,
supportsConcurrentWrites: ws.supportsConcurrentWrites,
onFailure: (err) =>
this.closePhysicalSocket({
ws,