Skip to content
Open
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
35 changes: 35 additions & 0 deletions lib/MessageDecoder.labelindex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,41 @@ describe('MessageDecoder label index', () => {
expect(result.decoder.name).toBe('catch-all');
});

test('wildcard registration order is preserved across existing label buckets', () => {
// Regression test: when a wildcard plugin is registered AFTER a label
// bucket has already been created, it must be inserted at the end of
// the wildcard section, not at the front. Otherwise later-registered
// wildcards would incorrectly take precedence over earlier ones for
// labels that already had buckets, and wildcard order would diverge
// from buckets created later (which seed from wildcardEntries.slice()).
const decoder = new MessageDecoder();

// 1. Register a label-specific plugin for '99' — this creates a
// bucket with no wildcards yet (existing wildcards from the
// constructor are in, but that's fine; we only care about the
// relative order of stubs registered here).
const labelSpecific = new StubPlugin(decoder, 'label-99-specific', ['99']);
decoder.registerPlugin(labelSpecific);

// 2. Register two new wildcard stubs in order.
const firstWildcard = new StubPlugin(decoder, 'wild-first', ['*']);
decoder.registerPlugin(firstWildcard);
const secondWildcard = new StubPlugin(decoder, 'wild-second', ['*']);
decoder.registerPlugin(secondWildcard);

// 3. For a label-99 message, the first-registered wildcard must run
// before the second-registered wildcard. Since both succeed, the
// decoder.name tells us which one won the race.
const result = decoder.decode({ label: '99', text: 'anything' });
expect(result.decoded).toBe(true);
expect(result.decoder.name).toBe('wild-first');

// 4. The same ordering must hold for a brand-new label that didn't
// have a bucket yet (it gets seeded from wildcardEntries.slice()).
const newLabel = decoder.decode({ label: 'ZZ', text: 'anything' });
expect(newLabel.decoder.name).toBe('wild-first');
});

test('preamble-based plugins only match correct preambles', () => {
const decoder = new MessageDecoder();
const stub = new StubPlugin(
Expand Down
110 changes: 76 additions & 34 deletions lib/MessageDecoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,15 +80,27 @@ const pluginClasses = [
Plugins.Label_QS,
];

/**
* Per-plugin metadata captured at registration time so that decode() can
* avoid re-invoking plugin.qualifiers() (and re-allocating its arrays) on
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

will that even save anything?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fair question — I benched it to check. Micro-benchmark on this laptop, 1.4M decodes across a mixed corpus (label-specific hits, wildcard wrappers, unknown labels), three runs each with warmup:

per-decode
baseline (main) ~8.09 µs
this PR ~7.44 µs

So ~8% faster. Variance is real (one run was within noise), but the trend held across runs. The wins come from avoiding these per-decode allocations on the hot path:

  • spread [...wildcards, ...labelPlugins] (new array)
  • new Set() for dedup
  • a dedup candidates array
  • .filter(...) (another array + closure)
  • plugin.qualifiers() on every candidate — returns a fresh {labels, preambles} each call

On the memory side: the pre-built candidatesByLabel Map holds ~30 entries (one per registered label) pointing at arrays of references to ~65 shared PluginEntry objects. Order of a few KB, one-time at construction — it's not per-decode. So it's not really "taking up memory" in a hot-path sense.

TL;DR: modest speedup, negligible memory overhead, and it removes per-call GC pressure (the spread+Set+filter churn was the loudest signal). Happy to pull it back if you'd prefer the simpler shape — the label index lookup alone (the 1.8.x change) already does most of the heavy lifting; this is an incremental win on top.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maintainer review: Author replied with a 1.4M-decode microbenchmark showing ~8% per-decode improvement. The cache eliminates a plugin.qualifiers() call and array allocation on every message — change is real and worth keeping. Resolved.

* every message.
*/
interface PluginEntry {
plugin: DecoderPluginInterface;
preambles: string[] | undefined;
}

export class MessageDecoder {
name: string;
plugins: Array<DecoderPluginInterface>;
debug: boolean;

/** Maps a label string to the plugins registered for it, preserving registration order. */
private labelIndex: Map<string, DecoderPluginInterface[]> = new Map();
/** Plugins that match all labels (qualifier label '*'). */
private wildcardPlugins: DecoderPluginInterface[] = [];
/** Maps a label string to the candidate entries (wildcard + label-specific) in registration order. */
private candidatesByLabel: Map<string, PluginEntry[]> = new Map();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

this seems like we're just taking up memory. is it really faster?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same question, replying once to avoid repetition — see my reply on the related thread for the benchmark numbers (~8% faster per decode with negligible memory overhead, since candidatesByLabel is built once at construction and holds shared references).


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Maintainer review: candidatesByLabel is built once at construction (one shared PluginEntry per plugin, reused across buckets). Memory cost is O(plugins x labels-per-plugin) and effectively constant. Hot-path savings of ~8% from the benchmark above justify the bookkeeping. Resolved.

/** Wildcard entries (plugins that register the '*' label). */
private wildcardEntries: PluginEntry[] = [];
/** Membership set for wildcard entries to dedupe when a plugin also registers a specific label. */
private wildcardSet: Set<DecoderPluginInterface> = new Set();

constructor() {
this.name = 'acars-decoder-typescript';
Expand All @@ -104,47 +116,59 @@ export class MessageDecoder {
this.plugins.push(plugin);

const qualifiers = plugin.qualifiers();
const entry: PluginEntry = {
plugin,
preambles:
qualifiers.preambles && qualifiers.preambles.length > 0
? qualifiers.preambles
: undefined,
};

for (const label of qualifiers.labels) {
if (label === '*') {
this.wildcardPlugins.push(plugin);
if (!this.wildcardSet.has(plugin)) {
this.wildcardEntries.push(entry);
this.wildcardSet.add(plugin);
// Insert the new wildcard at the end of the wildcard section
// in every existing label bucket so that wildcard plugins are
// still tried before label-specific ones while preserving
// registration order among wildcard plugins (matching how new
// buckets are seeded via wildcardEntries.slice() below).
const wildcardInsertIndex = this.wildcardEntries.length - 1;
for (const bucket of this.candidatesByLabel.values()) {
bucket.splice(wildcardInsertIndex, 0, entry);
}
}
} else {
let bucket = this.labelIndex.get(label);
let bucket = this.candidatesByLabel.get(label);
if (!bucket) {
bucket = [];
this.labelIndex.set(label, bucket);
// Seed new bucket with all wildcard entries (in registration order)
// so they remain ahead of label-specific plugins.
bucket = this.wildcardEntries.slice();
this.candidatesByLabel.set(label, bucket);
}
// Skip if this plugin is a wildcard plugin already in the bucket.
if (!this.wildcardSet.has(plugin)) {
bucket.push(entry);
}
bucket.push(plugin);
}
}

return true;
}

decode(message: Message, options: Options = {}): DecodeResult {
// Build candidate list: wildcard plugins first (e.g. CBand wrapper),
// then label-specific plugins, preserving registration order.
// Use a Set to prevent duplicate execution if a plugin registers both '*' and a specific label.
const labelPlugins = this.labelIndex.get(message.label) ?? [];
const seen = new Set<DecoderPluginInterface>();
const candidates: DecoderPluginInterface[] = [];
for (const plugin of [...this.wildcardPlugins, ...labelPlugins]) {
if (!seen.has(plugin)) {
seen.add(plugin);
candidates.push(plugin);
}
}

const usablePlugins = candidates.filter((plugin) => {
const preambles = plugin.qualifiers().preambles;
if (!preambles || preambles.length === 0) {
return true;
}
return preambles.some((p: string) => message.text.startsWith(p));
});
const text = message.text;
const candidates =
this.candidatesByLabel.get(message.label) ?? this.wildcardEntries;

if (options.debug) {
console.log('Usable plugins');
console.log(usablePlugins);
console.log(
candidates
.filter((e) => this.matchesPreambles(text, e.preambles))
.map((e) => e.plugin),
);
}

let result: DecodeResult = {
Expand All @@ -157,7 +181,7 @@ export class MessageDecoder {
},
message: message,
remaining: {
text: message.text,
text: text,
},
raw: {},
formatted: {
Expand All @@ -166,9 +190,12 @@ export class MessageDecoder {
},
};

for (let i = 0; i < usablePlugins.length; i++) {
const plugin = usablePlugins[i];
result = plugin.decode(message, options);
for (let i = 0; i < candidates.length; i++) {
const entry = candidates[i];
if (!this.matchesPreambles(text, entry.preambles)) {
continue;
}
result = entry.plugin.decode(message, options);
if (result.decoded) {
break;
}
Expand All @@ -181,4 +208,19 @@ export class MessageDecoder {

return result;
}

private matchesPreambles(
text: string,
preambles: string[] | undefined,
): boolean {
if (!preambles) {
return true;
}
for (let i = 0; i < preambles.length; i++) {
if (text.startsWith(preambles[i])) {
return true;
}
}
return false;
}
}
62 changes: 62 additions & 0 deletions lib/plugins/ARINC_702.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { MessageDecoder } from '../MessageDecoder';
import { Arinc702 } from './ARINC_702';

describe('ARINC_702 wildcard wrapper', () => {
let plugin: Arinc702;
const message = { label: 'H1', text: '' };

beforeEach(() => {
const decoder = new MessageDecoder();
plugin = new Arinc702(decoder);
});

test('matches wildcard qualifier', () => {
expect(plugin.qualifiers()).toEqual({ labels: ['*'] });
expect(plugin.name).toBe('arinc-702');
});

test('strips embedded CR/LF before delegating to the H1 helper', () => {
// Inserting newlines into a known-good H1 REQ POS payload should not
// prevent it from being decoded.
message.text = 'REQ\nPOS\r037B';
const result = plugin.decode(message);

expect(result.decoded).toBe(true);
expect(result.decoder.decodeLevel).toBe('full');
expect(result.raw.checksum).toBe(0x037b);
expect(result.formatted.description).toBe('Request for Position Report');
});

test('peels a leading / header before delegating', () => {
// /HDQDLUA.<H1 message body>
message.text = '/HDQDLUA.REQPOS037B';
const result = plugin.decode(message);

expect(result.decoded).toBe(true);
// The delegated H1 body must actually be decoded — assert the
// REQ POS fields landed in the result, proving the '/HDQDLUA.'
// prefix was peeled before delegation.
expect(result.formatted.description).toBe('Request for Position Report');
expect(result.raw.checksum).toBe(0x037b);
// The unparsed header also ends up in remaining text.
expect(result.remaining.text).toContain('/HDQDLUA');
});

test('returns not-decoded when nothing matches', () => {
message.text = 'totally bogus payload that no H1 rule matches';
const result = plugin.decode(message);

expect(result.decoded).toBe(false);
expect(result.decoder.decodeLevel).toBe('none');
// The full original text should be present in the remaining text the
// wrapper accumulates (the wrapper may also emit prefix fragments).
expect(result.remaining.text).toContain(message.text);
});

test('end-to-end via MessageDecoder routes ARINC 702 wildcard for H1', () => {
const decoder = new MessageDecoder();
const result = decoder.decode({ label: 'H1', text: 'REQPOS037B' });
expect(result.decoded).toBe(true);
expect(result.formatted.description).toBe('Request for Position Report');
});
});
6 changes: 5 additions & 1 deletion lib/plugins/ARINC_702.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { DecodeResult, Message, Options } from '../DecoderPluginInterface';
import { Arinc702Helper } from '../utils/arinc_702_helper';
import { ResultFormatter } from '../utils/result_formatter';

// Hoisted to module scope so the wildcard decode() doesn't recompile the
// pattern for every incoming message.
const NEWLINE_REGEX = /[\n\r]/g;

// TODO: come up with a better name as this decodes multiple labels
export class Arinc702 extends DecoderPlugin {
name = 'arinc-702';
Expand All @@ -17,7 +21,7 @@ export class Arinc702 extends DecoderPlugin {
decodeResult.decoder.name = this.name;
decodeResult.message = message;

const msg = message.text.replace(/\n|\r/g, '');
const msg = message.text.replace(NEWLINE_REGEX, '');

// try to decode the entire message
let decoded = false;
Expand Down
77 changes: 48 additions & 29 deletions lib/plugins/CBand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ import { DecodeResult, Message, Options } from '../DecoderPluginInterface';
import { MIAMCoreUtils } from '../utils/miam';
import { ResultFormatter } from '../utils/result_formatter';

// C-Band puts a 10 char header in front of some message types:
// chars 0-3: message number ([A-Z]\d{2}[A-Z])
// chars 4-5: airline code ([A-Z0-9]{2})
// chars 6-9: flight number ([0-9]{4})
// Hoisted to module scope so the pattern is allocated once instead of on
// every wildcard decode invocation.
const CBAND_HEADER =
/^(?<msgno>[A-Z]\d{2}[A-Z])(?<airline>[A-Z0-9]{2})(?<number>[0-9]{4})/;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export class CBand extends DecoderPlugin {
name = 'c-band';
qualifiers() {
Expand All @@ -12,39 +21,49 @@ export class CBand extends DecoderPlugin {
}

decode(message: Message, options: Options = {}): DecodeResult {
let decodeResult = this.defaultResult();
const text = message.text;
// Cheap length check before running the regex — a C-Band header is
// exactly 10 chars and is followed by the wrapped payload.
if (text.length < 10) {
const result = this.defaultResult();
result.decoder.name = this.name;
result.message = message;
return result;
}

const cband = CBAND_HEADER.exec(text);
if (!cband?.groups) {
const result = this.defaultResult();
result.decoder.name = this.name;
result.message = message;
return result;
}

const decoded = this.decoder.decode(
{
label: message.label,
sublabel: message.sublabel,
text: text.substring(10),
},
options,
);

const decodeResult = this.defaultResult();
decodeResult.decoder.name = this.name;
decodeResult.message = message;

// C-Band puts a 10 char header in front of some message types
// First 4 chars are some kind of message number
// Last 6 chars are the flight number
let cband = message.text.match(
/^(?<msgno>[A-Z]\d{2}[A-Z])(?<airline>[A-Z0-9]{2})(?<number>[0-9]{4})/,
);
if (cband?.groups) {
const messageText = message.text.substring(10);
const decoded = this.decoder.decode(
{
label: message.label,
sublabel: message.sublabel,
text: messageText,
},
options,
if (decoded.decoded) {
ResultFormatter.flightNumber(
decodeResult,
cband.groups.airline + Number(cband.groups.number),
);
if (decoded.decoded) {
ResultFormatter.flightNumber(
decodeResult,
cband.groups.airline + Number(cband.groups.number),
);
decodeResult.decoded = true;
decodeResult.decoder.decodeLevel = decoded.decoder.decodeLevel;
decodeResult.decoder.name = this.name + '-' + decoded.decoder.name;
decodeResult.raw = { ...decodeResult.raw, ...decoded.raw };
decodeResult.formatted.description = decoded.formatted.description;
decodeResult.formatted.items.push(...decoded.formatted.items);
decodeResult.remaining = decoded.remaining;
}
decodeResult.decoded = true;
decodeResult.decoder.decodeLevel = decoded.decoder.decodeLevel;
decodeResult.decoder.name = this.name + '-' + decoded.decoder.name;
decodeResult.raw = { ...decodeResult.raw, ...decoded.raw };
decodeResult.formatted.description = decoded.formatted.description;
decodeResult.formatted.items.push(...decoded.formatted.items);
decodeResult.remaining = decoded.remaining;
}
return decodeResult;
}
Expand Down
3 changes: 2 additions & 1 deletion lib/plugins/Label_4A.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export class Label_4A extends DecoderPlugin {
// ResultFormatter.altitude(decodeResult, Number(alt) * 100);
ResultFormatter.unknownArr(decodeResult, fields.slice(8));
} else if (fields.length === 6) {
if (fields[0].match(/^[NS]/)) {
const f0 = fields[0];
if (f0.length > 0 && (f0[0] === 'N' || f0[0] === 'S')) {
// variant 2
ResultFormatter.position(
decodeResult,
Expand Down
Loading
Loading