diff --git a/.github/skills/tcf/SKILL.md b/.github/skills/tcf/SKILL.md new file mode 100644 index 0000000..9b35369 --- /dev/null +++ b/.github/skills/tcf/SKILL.md @@ -0,0 +1,131 @@ +--- +name: tcf +description: TCF (Target Communication Framework) protocol and service reference for this extension's TCF client (src/tcf/), and a CLI to run TCF commands on a live agent. Use when adding or changing TCF commands, events or service wrappers, reading TCF traces, looking up what a TCF service command takes and returns, or checking what a running TCF agent (e.g. Simics on localhost:1534) really replies. +argument-hint: TCF service, command, event, wire protocol +--- + +# TCF-cli tool for the Simics VS Code debugger + +This extension is a TCF *client*. It connects over TCP to a TCF agent (usually +the Simics TCF agent), and translates DAP requests to TCF commands and TCF +events to DAP events. It provides no services of its own. + +## Official documentation + +Fetch the spec page before adding or changing a command: do not guess argument +order, reply fields or property names. + +- [TCF Specification](https://download.eclipse.org/tools/tcf/tcf-docs/TCF%20Specification.html) + (wire protocol, `Locator.Hello`), + [Syntax Rules Notation](https://download.eclipse.org/tools/tcf/tcf-docs/TCF%20Syntax%20Rules%20Notation.html), + [Error Report Format](https://download.eclipse.org/tools/tcf/tcf-docs/TCF%20Error%20Report%20Format.html), + [Context Identifiers](https://download.eclipse.org/tools/tcf/tcf-docs/TCF%20Context%20Identifier%20Explanation.html), + [TCF Services](https://download.eclipse.org/tools/tcf/tcf-docs/TCF%20Services.html). +- Service pages: + `https://download.eclipse.org/tools/tcf/tcf-docs/TCF%20Service%20-%20.html`, + with the readable name (`Run Control`, `Stack Trace`, `Line Numbers`, + `Memory Map`, `Path Map`, `Context Query`). The Java API at the end lists the + wire name (`NAME`) and property names (`PROP_*`). +- Raw HTML, also when the site is down: + `https://gitlab.eclipse.org/eclipse/tcf/tcf/-/raw/master/docs/`. + The web fetch drops anchors, so find them there (`#Cmd`, `#Events`, + `#prop`, some with typos such as `#propeEnabled`). +- Where the spec is vague, the reference agent shows the real behavior: + + (`agent/tcf/services/*.c`). + +When answering, link every claim to where it is defined or implemented, with +verified line numbers: the spec page with its anchor, the reference agent on +GitLab, or workspace-relative links into this repo (the links in this skill are +relative to the skill file). + +## Protocol basics + +- TCP; the Simics tcf-agent listens on port 1534 by default. A message is + zero-terminated fields, each one JSON value. Binary data is base64 (this + client never announces `ZeroCopy`). +- Message types (`•` = zero byte): + `C • • • • `, + `R • • ` (final), `P` (progress), `N • ` (unknown + service or command, or sent before the channel is connected), + `E • • • `, `F` (flow control). Replies are + matched to commands by token only. +- Both peers send `E • Locator • Hello • ` first. Check the + peer's list before using optional services such as `ContextQuery`. +- Most replies start with an error report: empty or `null` on success (parsed + as `undefined`), else an object with `Code`, `Format`, `Params`, ... Some + replies put it in a later field (for example `Memory.get`), so read the reply + syntax on the service page. +- Addresses and PCs are 64-bit integers as plain JSON numbers, which lose + precision in `JSON.parse`. + +## Contexts + +- A context ID is an opaque string; do not parse it. The same ID is valid + across services, each with its own properties of it. +- Walk hierarchies with `getChildren()` and `getContext()`. + `RunControl` and `Memory` take `""` as the root; `Registers` and `StackTrace` + are rooted in `RunControl` contexts. `ContextQuery.query` selects contexts, + e.g. `**/HasState=true`. +- `containerSuspended • • • • • ` replaces + `contextSuspended` for all ``, but PC, reason and state data are only + for the trigger ``; get the others with `getState` (`getMinState` has no + PC). `containerResumed` is the same for resume. +- In Simics, all contexts with state run or stop together, so the agent sends + only `containerSuspended`/`containerResumed`, listing every context with + `HasState`. It also sends them while the simulation keeps running: around a + proxy lock, and as a pair to refresh clients (e.g. after loading a snapshot). + The trigger is a context asked to suspend (step, breakpoint), else the OS + awareness node or the stopping CPU. +- Simics `getState`: `suspended` is the state of the whole simulation. A PC + that cannot be read comes back as 0 without `PCError`. + +## Services + +Each standard service has a spec page (see above) with its commands, events +and properties, and a wrapper class in +[src/tcf/services/](../../../src/tcf/services/). Check both before using a +service. + +The Simics tcf-agent also has specific services, which are not on the Eclipse +site, and require access to the Simics source. + +## This repo's TCF client + +The client is in [src/tcf/](../../../src/tcf/): `TcfChannel` in channel.ts, +JSON with `bigint` in json.ts, one wrapper per service in services/. Their doc +comments explain which call to use. + +To add a command or event: read its spec page, add a method to the service +wrapper, pick `command` or `sendCommand` from the reply syntax, use `bigint` +for addresses, check `hasService` if the service is optional, and add a unit +test. + +## Running commands on a live agent + +[scripts/tcf-cli.ts](../../../scripts/tcf-cli.ts) connects to an agent with the +extension's `TcfChannel` and service wrappers. Use it when the user asks what +an agent replies, or to check a reply shape before coding against it. Build it +with `npm run -s compile`; then `./out/tcf-cli.js --help` lists the options, +`help()` the helpers. + +- One-off: `./out/tcf-cli.js [--host H] [--port P] -e ''`, + where the JavaScript is the body of an async function with the helpers in + scope, or a `.js` file path instead of `-e`. +- Session: `./out/tcf-cli.js` starts a REPL with top-level `await`. + Run it in async mode and send one line at a time with `waitForOutput`. +- Helpers: `cmd(service, command, ...args)` (checks the error report), + `raw(...)` (all fields unchecked), `services()`, `contexts()`, + `tree(service, root)`, wrappers + `rc bp mem mmap regs st expr sym lines pmap dis cl`, `print` (keeps 64-bit + integers), `hex`. +- Events are recorded from connecting on: `events()`, `mark()`, + `waitEvent(service, event, timeoutMs, after)`; take `mark()` before the + command that triggers the event. `live()` and `trace()` print as messages + come. Timeouts are 10 s (`--timeout MS`). +- Example: `./out/tcf-cli.js -e 'await tree("RunControl", "")'` + +Reading is fine. Ask the user every time before anything that changes the +target or the session: `resume`, `suspend`, `set` commands, adding or changing +breakpoints, and Simics-specific commands that are not plain reads. An open VS +Code debug session keeps working, but sees the effects. diff --git a/esbuild.mjs b/esbuild.mjs index 9c7703a..d54f6d4 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -18,7 +18,7 @@ const watchPlugins = () => setup(build) { let ready = false; build.onStart(() => { - if (running++ === 0 && readyBuilds === (tests ? 1 : 3)) { + if (running++ === 0 && readyBuilds === (tests ? 1 : 4)) { console.log("Build started."); } }); @@ -28,7 +28,7 @@ const watchPlugins = () => ready = true; readyBuilds++; } - if (running === 0 && readyBuilds === (tests ? 1 : 3)) { + if (running === 0 && readyBuilds === (tests ? 1 : 4)) { console.log("Build complete; watching for changes."); } }); @@ -37,6 +37,20 @@ const watchPlugins = () => ] : []; +const tcfCliPlugins = () => [ + ...watchPlugins(), + { + name: "make-tcf-cli-executable", + setup(build) { + build.onEnd((result) => { + if (result.errors.length === 0) { + fs.chmodSync("out/tcf-cli.js", 0o755); + } + }); + }, + }, +]; + const common = { bundle: true, format: "cjs", @@ -67,45 +81,54 @@ const contexts = tests : [ await esbuild.context({ ...common, - entryPoints: ["src/extension.ts"], - plugins: watchPlugins(), - outfile, - sourcemap: !production, - minify: production, - }), - // The Target Memory webview, also writes out/target-memory.css. - await esbuild.context({ - entryPoints: ["src/webview/targetMemory/index.tsx"], - plugins: watchPlugins(), - bundle: true, - format: "iife", - platform: "browser", - target: "es2022", - outfile: "out/target-memory.js", - jsx: "automatic", - loader: { ".ttf": "dataurl" }, - define: { "process.env.NODE_ENV": JSON.stringify(production ? "production" : "development") }, - sourcemap: !production, - minify: production, - logLevel: "info", - }), - // The launch settings form, also writes out/webview/launchSettings.css. - await esbuild.context({ - ...common, - format: "iife", - platform: "browser", - target: "es2022", - plugins: watchPlugins(), - entryPoints: [ - "src/launchSettings/webview/launchSettings.ts", - "src/launchSettings/webview/launchSettings.css", - ], - outdir: "out/webview", - loader: { ".ttf": "dataurl" }, - sourcemap: !production, - minify: production, + entryPoints: ["scripts/tcf-cli.ts"], + plugins: tcfCliPlugins(), + outfile: "out/tcf-cli.js", + banner: { js: "#!/usr/bin/env node\nprocess.setSourceMapsEnabled(true);" }, + sourcemap: true, + logLevel: "warning", }), - ]; + await esbuild.context({ + ...common, + entryPoints: ["src/extension.ts"], + plugins: watchPlugins(), + outfile, + sourcemap: !production, + minify: production, + }), + // The Target Memory webview, also writes out/target-memory.css. + await esbuild.context({ + entryPoints: ["src/webview/targetMemory/index.tsx"], + plugins: watchPlugins(), + bundle: true, + format: "iife", + platform: "browser", + target: "es2022", + outfile: "out/target-memory.js", + jsx: "automatic", + loader: { ".ttf": "dataurl" }, + define: { "process.env.NODE_ENV": JSON.stringify(production ? "production" : "development") }, + sourcemap: !production, + minify: production, + logLevel: "info", + }), + // The launch settings form, also writes out/webview/launchSettings.css. + await esbuild.context({ + ...common, + format: "iife", + platform: "browser", + target: "es2022", + plugins: watchPlugins(), + entryPoints: [ + "src/launchSettings/webview/launchSettings.ts", + "src/launchSettings/webview/launchSettings.css", + ], + outdir: "out/webview", + loader: { ".ttf": "dataurl" }, + sourcemap: !production, + minify: production, + }), + ]; if (watch) { await Promise.all(contexts.map((ctx) => ctx.watch())); diff --git a/scripts/tcf-cli.ts b/scripts/tcf-cli.ts new file mode 100644 index 0000000..1ef9b06 --- /dev/null +++ b/scripts/tcf-cli.ts @@ -0,0 +1,336 @@ +/* + * A TCF client for trying commands on a running agent, built on the + * extension's TcfChannel. Usage: see usage() below and ../.github/skills/tcf/SKILL.md. + */ +import * as fs from "node:fs"; +import * as repl from "node:repl"; +import { TcfChannel, stringifyJson } from "../src/tcf/channel"; +import { toBigInt } from "../src/tcf/json"; +import { BreakpointsService } from "../src/tcf/services/breakpoints"; +import { CommandLineService } from "../src/tcf/services/commandLine"; +import { DisassemblyService } from "../src/tcf/services/disassembly"; +import { ExpressionsService } from "../src/tcf/services/expressions"; +import { LineNumbersService } from "../src/tcf/services/lineNumbers"; +import { MemoryService } from "../src/tcf/services/memory"; +import { MemoryMapService } from "../src/tcf/services/memoryMap"; +import { PathMapService } from "../src/tcf/services/pathMap"; +import { RegistersService } from "../src/tcf/services/registers"; +import { RunControlService } from "../src/tcf/services/runControl"; +import { StackTraceService } from "../src/tcf/services/stackTrace"; +import { SymbolsService } from "../src/tcf/services/symbols"; + +function usage(): string { + return `Usage: ./out/tcf-cli.js [options] [-e CODE | FILE] + +Connects to a TCF agent. With -e or FILE, runs the JavaScript as the body of +an async function and exits; otherwise starts a REPL (top-level await works). +Type help() in either for the helpers. + +Options: + --host HOST agent host (default localhost) + --port PORT agent port (default 1534) + --timeout MS timeout of commands and waitEvent (default 10000) + --trace print all TCF messages, like "traceTcf" + -e CODE run CODE + FILE run the JavaScript in FILE`; +} + +const HELP = `Commands + cmd(service, command, ...args) reply after the error report: one field as is, + several as an array; throws on error or N + raw(service, command, ...args) all reply fields unchecked (Memory.get, ...) + services() services in the agent's Locator.Hello +Service wrappers of the extension + rc bp mem mmap regs st expr sym lines pmap dis cl +Contexts + contexts(service="RunControl", filter?, root="") all contexts, depth first + tree(service="RunControl", root="") print the context tree +Events (recorded from connecting on) + events(service?, event?) recorded events {seq, time, service, event, args} + clearEvents() forget recorded events + mark() sequence number of the last event + waitEvent(service, event?, timeoutMs?, after=mark()) + first matching event after 'after', or the next one + live(on=true) print events as they arrive +Output + print(...values) JSON with 64-bit integers as numbers hex(n) "0x..." string + trace(on=true) print all TCF messages close() disconnect +Addresses are bigint: write 0x1000n.`; + +interface Options { + host: string; + port: number; + timeout: number; + trace: boolean; + code?: string; + file?: string; +} + +interface RecordedEvent { + seq: number; + time: string; + service: string; + event: string; + args: unknown[]; +} + +type Context = Record & { ID: string }; + +// At least this many of the latest events are kept; old ones are dropped in bulk. +const MAX_EVENTS = 10000; + +function parseArgs(argv: string[]): Options { + const o: Options = { host: "localhost", port: 1534, timeout: 10000, trace: false }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const value = () => { + const v = argv[++i]; + if (v === undefined) { + throw new Error(`${arg} needs a value`); + } + return v; + }; + switch (arg) { + case "--host": + o.host = value(); + break; + case "--port": { + const v = value(); + const port = Number(v); + if (!Number.isFinite(port) || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid value for ${arg}: '${v}'. Expected an integer between 1 and 65535.`); + } + o.port = port; + break; + } + case "--timeout": { + const v = value(); + const timeout = Number(v); + if (!Number.isFinite(timeout)) { + throw new Error(`Invalid value for ${arg}: '${v}'. Expected a finite number (milliseconds).`); + } + o.timeout = timeout; + break; + } + case "--trace": + o.trace = true; + break; + case "-e": + o.code = value(); + break; + case "-h": + case "--help": + console.log(usage()); + process.exit(0); + break; + default: + if (arg.startsWith("-") || o.file !== undefined) { + throw new Error(`Unknown argument '${arg}'\n\n${usage()}`); + } + o.file = arg; + } + } + return o; +} + +/** Indented JSON, with bigint written as plain numbers. */ +function toJson(value: unknown): string { + const json = JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? `\u0000bigint:${v}` : v), 2); + return json === undefined ? String(value) : json.replace(/"\\u0000bigint:(-?\d+)"/g, "$1"); +} + +function print(...values: unknown[]): void { + console.log(values.map((v) => (typeof v === "string" ? v : toJson(v))).join(" ")); +} + +function withTimeout(promise: Promise, ms: number, what: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`${what}: no reply within ${ms} ms`)), ms); + promise.then( + (v) => { + clearTimeout(timer); + resolve(v); + }, + (err: unknown) => { + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + }, + ); + }); +} + +function makeHelpers(channel: TcfChannel, o: Options, setTrace: (on: boolean) => void) { + const recorded: RecordedEvent[] = []; + const waiters = new Set<(e: RecordedEvent) => void>(); + let seq = 0; + let liveEvents = false; + + channel.onAnyEvent((service, event, args) => { + const e = { seq: ++seq, time: new Date().toISOString().slice(11, 23), service, event, args }; + recorded.push(e); + if (recorded.length >= 2 * MAX_EVENTS) { + recorded.splice(0, recorded.length - MAX_EVENTS); + } + if (liveEvents) { + console.log( + `[event ${e.seq} ${e.time}] ${service} ${event} ${args.map((a) => stringifyJson(a)).join(" ")}`, + ); + } + for (const w of [...waiters]) { + w(e); + } + }); + + const matches = (e: RecordedEvent, service?: string, event?: string) => + (service === undefined || e.service === service) && (event === undefined || e.event === event); + + const cmd = async (service: string, command: string, ...args: unknown[]): Promise => { + const rest = await withTimeout(channel.command(service, command, ...args), o.timeout, `${service}.${command}`); + return rest.length === 1 ? rest[0] : rest; + }; + + const contexts = async ( + service = "RunControl", + filter?: (ctx: Context) => boolean, + root = "", + ): Promise => { + const result: Context[] = []; + const walk = async (parent: string): Promise => { + const ids = await cmd(service, "getChildren", parent); + for (const id of Array.isArray(ids) ? (ids as string[]) : []) { + const ctx = (await cmd(service, "getContext", id)) as Context; + if (!filter || filter(ctx)) { + result.push(ctx); + } + await walk(id); + } + }; + await walk(root); + return result; + }; + + return { + channel, + cmd, + raw: (service: string, command: string, ...args: unknown[]) => + withTimeout(channel.sendCommand(service, command, args), o.timeout, `${service}.${command}`), + services: () => [...channel.remoteServices], + rc: new RunControlService(channel), + bp: new BreakpointsService(channel), + mem: new MemoryService(channel), + mmap: new MemoryMapService(channel), + regs: new RegistersService(channel), + st: new StackTraceService(channel), + expr: new ExpressionsService(channel), + sym: new SymbolsService(channel), + lines: new LineNumbersService(channel), + pmap: new PathMapService(channel), + dis: new DisassemblyService(channel), + cl: new CommandLineService(channel), + contexts, + tree: async (service = "RunControl", root = ""): Promise => { + const walk = async (parent: string, indent: string): Promise => { + const ids = await cmd(service, "getChildren", parent); + for (const id of Array.isArray(ids) ? (ids as string[]) : []) { + const ctx = (await cmd(service, "getContext", id)) as Context; + const flags = ["HasState", "IsContainer"].filter((k) => ctx[k] === true).join(" "); + const name = typeof ctx.Name === "string" && ctx.Name !== id ? ` "${ctx.Name}"` : ""; + console.log(`${indent}${id}${name}${flags ? ` [${flags}]` : ""}`); + await walk(id, `${indent} `); + } + }; + await walk(root, ""); + }, + events: (service?: string, event?: string) => recorded.filter((e) => matches(e, service, event)), + clearEvents: () => { + recorded.length = 0; + }, + mark: () => seq, + waitEvent: (service: string, event?: string, timeoutMs = o.timeout, after = seq): Promise => { + const found = recorded.find((e) => e.seq > after && matches(e, service, event)); + if (found) { + return Promise.resolve(found); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + waiters.delete(waiter); + reject(new Error(`No ${service} ${event ?? "event"} within ${timeoutMs} ms`)); + }, timeoutMs); + const waiter = (e: RecordedEvent) => { + if (matches(e, service, event)) { + clearTimeout(timer); + waiters.delete(waiter); + resolve(e); + } + }; + waiters.add(waiter); + }); + }, + live: (on = true) => { + liveEvents = on; + }, + trace: (on = true) => setTrace(on), + print, + hex: (n: unknown) => { + const b = toBigInt(n); + return b === undefined ? String(n) : `0x${b.toString(16)}`; + }, + help: () => console.log(HELP), + close: () => channel.close(), + }; +} + +async function main(): Promise { + const o = parseArgs(process.argv.slice(2)); + const code = o.code ?? (o.file !== undefined ? fs.readFileSync(o.file, "utf8") : undefined); + let tracing = o.trace; + const channel = new TcfChannel((dir, text) => { + if (tracing) { + console.log(`TCF ${dir} ${text}`); + } + }); + channel.on("error", (err: Error) => console.error(`TCF error: ${err.message}`)); + await channel.connect(o.host, o.port, o.timeout); + const helpers = makeHelpers(channel, o, (on) => { + tracing = on; + }); + + if (code !== undefined) { + const AsyncFunction = Object.getPrototypeOf(async () => undefined).constructor as new ( + ...args: string[] + ) => (...values: unknown[]) => Promise; + const names = Object.keys(helpers); + try { + const result = await new AsyncFunction(...names, code)(...Object.values(helpers)); + if (result !== undefined) { + print(result); + } + } finally { + channel.close(); + } + return; + } + + console.log(`Connected to ${o.host}:${o.port}. Services: ${channel.remoteServices.join(" ")}`); + console.log("Type help() for the helpers, .exit to quit."); + const server = repl.start({ prompt: "tcf> " }); + Object.assign(server.context, helpers); + let exiting = false; + channel.on("close", () => { + if (!exiting) { + console.log("\nThe agent closed the connection."); + server.close(); + } + }); + await new Promise((resolve) => server.on("exit", resolve)); + exiting = true; + channel.close(); +} + +main().then( + () => process.exit(process.exitCode ?? 0), + (err: unknown) => { + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); + }, +); diff --git a/src/tcf/channel.ts b/src/tcf/channel.ts index 50598a5..847fec6 100644 --- a/src/tcf/channel.ts +++ b/src/tcf/channel.ts @@ -24,6 +24,7 @@ export class TcfError extends Error { message: string, readonly code?: number, readonly data?: unknown, + /** Set when the agent replied N: it does not know the service or command. */ readonly unsupported = false, ) { super(message); @@ -52,6 +53,7 @@ interface PendingCommand { } export type EventListener = (args: unknown[]) => void; +export type AnyEventListener = (service: string, event: string, args: unknown[]) => void; /** Formats a TCF error report object (or legacy error value) into a message. */ export function formatTcfError(err: unknown): string { @@ -180,6 +182,7 @@ export class TcfChannel extends EventEmitter { private nextToken = 1; private readonly pending = new Map(); private readonly eventListeners = new Map>(); + private readonly anyEventListeners = new Set(); private peerServices: string[] = []; private closed = false; @@ -300,6 +303,12 @@ export class TcfChannel extends EventEmitter { return () => set.delete(listener); } + /** Listens to every event, after the listeners of that event. Returns a function that removes the listener. */ + onAnyEvent(listener: AnyEventListener): () => void { + this.anyEventListeners.add(listener); + return () => this.anyEventListeners.delete(listener); + } + private sendRaw(fields: string[]): void { if (!this.socket || this.closed) { return; @@ -339,14 +348,22 @@ export class TcfChannel extends EventEmitter { } case "E": { const listeners = this.eventListeners.get(`${fields[1]}\0${fields[2]}`); - if (listeners) { - const args = fields.slice(3).map(parseField); - for (const l of listeners) { - try { - l(args); - } catch (err) { - this.emitError(err instanceof Error ? err : new Error(String(err))); - } + if (!listeners?.size && this.anyEventListeners.size === 0) { + break; + } + const args = fields.slice(3).map(parseField); + for (const l of listeners ?? []) { + try { + l(args); + } catch (err) { + this.emitError(err instanceof Error ? err : new Error(String(err))); + } + } + for (const l of this.anyEventListeners) { + try { + l(fields[1], fields[2], args); + } catch (err) { + this.emitError(err instanceof Error ? err : new Error(String(err))); } } break; diff --git a/src/test/channel.test.ts b/src/test/channel.test.ts index 73b667c..55e4188 100644 --- a/src/test/channel.test.ts +++ b/src/test/channel.test.ts @@ -31,6 +31,11 @@ function seededRandom(seed: number): () => number { }; } +// Resolves when the server and all its connections are closed. +function closeServer(server: net.Server): Promise { + return new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))); +} + // Each field is followed by a 0 byte, the message ends with ESC 1 (EOM). test("encodeMessage ends fields with 0 and messages with ESC 1", () => { assert.deepEqual( @@ -285,7 +290,7 @@ test("TcfChannel trace gives the type, service and name of each message", async await assert.rejects(channel.command("CommandLine", "bogus"), /not supported/); } finally { channel.close(); - server.close(); + await closeServer(server); } const keyPressed = { service: "CommandLine", name: "keyPressed" }; const bogus = { service: "CommandLine", name: "bogus" }; @@ -301,3 +306,43 @@ test("TcfChannel trace gives the type, service and name of each message", async ["<-", "N", { type: "command", ...bogus }], ]); }); + +test("TcfChannel onAnyEvent gets every event after its own listeners", async () => { + const server = net.createServer((socket) => { + const r = new MessageReader({ + message: (fields) => { + if (fields[0] !== "C") { + return; + } + socket.write(encodeMessage(["E", "RunControl", "containerResumed", '["a","b"]'])); + socket.write(encodeMessage(["E", "Memory", "memoryChanged", '"m"', "[]"])); + socket.write(encodeMessage(["R", fields[1], ""])); + }, + endOfStream: () => socket.end(), + error: () => undefined, + }); + socket.on("data", (data) => r.push(data as Buffer)); + socket.write(encodeMessage(["E", "Locator", "Hello", '["RunControl"]'])); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const channel = new TcfChannel(); + const seen: unknown[][] = []; + channel.onEvent("RunControl", "containerResumed", (args) => seen.push(["own", ...args])); + const remove = channel.onAnyEvent((service, event, args) => seen.push([service, event, ...args])); + try { + await channel.connect("127.0.0.1", (server.address() as net.AddressInfo).port, 5000); + await channel.command("RunControl", "suspend", "a"); + remove(); + await channel.command("RunControl", "suspend", "a"); + } finally { + channel.close(); + await closeServer(server); + } + assert.deepEqual(seen, [ + ["Locator", "Hello", ["RunControl"]], + ["own", ["a", "b"]], + ["RunControl", "containerResumed", ["a", "b"]], + ["Memory", "memoryChanged", "m", []], + ["own", ["a", "b"]], + ]); +}); diff --git a/tsconfig.json b/tsconfig.json index 6d59dba..443cf57 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "target": "ES2022", "lib": ["ES2022"], "outDir": "out", - "rootDir": "src", + "rootDir": ".", "strict": true, "noImplicitOverride": true, "noUnusedLocals": true, @@ -12,6 +12,6 @@ "skipLibCheck": true, "sourceMap": true }, - "include": ["src"], + "include": ["src", "scripts"], "exclude": ["src/webview", "src/launchSettings/webview"] }