diff --git a/src/common/StringBuilder.test.ts b/src/common/StringBuilder.test.ts new file mode 100644 index 000000000..1794de0f7 --- /dev/null +++ b/src/common/StringBuilder.test.ts @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { LimitedStringBuilder, StringBuilder } from 'common/StringBuilder'; + +describe('StringBuilder', () => { + it('should start empty', () => { + const builder = new StringBuilder(); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should append a single chunk', () => { + const builder = new StringBuilder(); + builder.append('hello'); + assert.strictEqual(builder.length, 5); + assert.strictEqual(builder.toString(), 'hello'); + }); + + it('should join multiple chunks in order', () => { + const builder = new StringBuilder(); + builder.append('foo'); + builder.append('bar'); + builder.append('baz'); + assert.strictEqual(builder.length, 9); + assert.strictEqual(builder.toString(), 'foobarbaz'); + }); + + it('should handle empty chunks', () => { + const builder = new StringBuilder(); + builder.append(''); + builder.append('a'); + builder.append(''); + assert.strictEqual(builder.length, 1); + assert.strictEqual(builder.toString(), 'a'); + }); + + it('should reset accumulated data', () => { + const builder = new StringBuilder(); + builder.append('hello'); + builder.reset(); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should allow appending after reset', () => { + const builder = new StringBuilder(); + builder.append('old'); + builder.reset(); + builder.append('new'); + assert.strictEqual(builder.toString(), 'new'); + }); + + it('should accumulate many small chunks without quadratic concatenation', () => { + const builder = new StringBuilder(); + const chunk = 'x'; + const count = 10000; + for (let i = 0; i < count; i++) { + builder.append(chunk); + } + assert.strictEqual(builder.length, count); + assert.strictEqual(builder.toString(), 'x'.repeat(count)); + }); +}); + +describe('LimitedStringBuilder', () => { + it('should expose the configured limit', () => { + const builder = new LimitedStringBuilder(42); + assert.strictEqual(builder.limit, 42); + }); + + it('should start empty', () => { + const builder = new LimitedStringBuilder(10); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should accept data up to the limit', () => { + const builder = new LimitedStringBuilder(10); + assert.strictEqual(builder.append('12345'), false); + assert.strictEqual(builder.append('67890'), false); + assert.strictEqual(builder.length, 10); + assert.strictEqual(builder.toString(), '1234567890'); + }); + + it('should accept a single chunk exactly at the limit', () => { + const builder = new LimitedStringBuilder(5); + assert.strictEqual(builder.append('abcde'), false); + assert.strictEqual(builder.length, 5); + assert.strictEqual(builder.toString(), 'abcde'); + }); + + it('should reject data exceeding the limit and clear the buffer', () => { + const builder = new LimitedStringBuilder(5); + builder.append('abc'); + assert.strictEqual(builder.append('def'), true); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should reject a single chunk larger than the limit', () => { + const builder = new LimitedStringBuilder(3); + assert.strictEqual(builder.append('toolong'), true); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should allow appending again after reset following a limit breach', () => { + const builder = new LimitedStringBuilder(3); + assert.strictEqual(builder.append('abcd'), true); + builder.reset(); + assert.strictEqual(builder.append('ab'), false); + assert.strictEqual(builder.toString(), 'ab'); + }); + + it('should accumulate many chunks before hitting the limit', () => { + const limit = 100; + const builder = new LimitedStringBuilder(limit); + const chunk = 'A'; + for (let i = 0; i < limit; i++) { + assert.strictEqual(builder.append(chunk), false); + } + assert.strictEqual(builder.toString(), 'A'.repeat(limit)); + assert.strictEqual(builder.append('B'), true); + assert.strictEqual(builder.toString(), ''); + }); + + it('should reject when limit is zero and any data is appended', () => { + const builder = new LimitedStringBuilder(0); + assert.strictEqual(builder.append('a'), true); + assert.strictEqual(builder.length, 0); + }); + + it('should allow zero-length appends at the limit', () => { + const builder = new LimitedStringBuilder(0); + assert.strictEqual(builder.append(''), false); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); +}); diff --git a/src/common/StringBuilder.ts b/src/common/StringBuilder.ts new file mode 100644 index 000000000..d9184ce31 --- /dev/null +++ b/src/common/StringBuilder.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +/** + * Accumulates string data from multiple chunks without O(n²) string concatenation. + */ +export class StringBuilder { + private _chunks: string[] = []; + private _length = 0; + + public get length(): number { + return this._length; + } + + public reset(): void { + this._chunks.length = 0; + this._length = 0; + } + + public append(chunk: string): void { + this._chunks.push(chunk); + this._length += chunk.length; + } + + public toString(): string { + return this._chunks.join(''); + } +} + +/** + * String builder that rejects payloads larger than a fixed limit. + */ +export class LimitedStringBuilder { + private readonly _builder = new StringBuilder(); + + constructor(private readonly _limit: number) { } + + public get length(): number { + return this._builder.length; + } + + public get limit(): number { + return this._limit; + } + + public reset(): void { + this._builder.reset(); + } + + /** + * @returns true if the limit was exceeded (buffer is cleared in that case) + */ + public append(chunk: string): boolean { + this._builder.append(chunk); + if (this._builder.length > this._limit) { + this._builder.reset(); + return true; + } + return false; + } + + public toString(): string { + return this._builder.toString(); + } +} diff --git a/src/common/parser/ApcParser.ts b/src/common/parser/ApcParser.ts index 8f921f8cb..4e647d037 100644 --- a/src/common/parser/ApcParser.ts +++ b/src/common/parser/ApcParser.ts @@ -7,6 +7,7 @@ import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, IS import { ParserConstants } from 'common/parser/Constants'; import { utf32ToString } from 'common/input/TextDecoder'; import { IDisposable } from 'common/Types'; +import { LimitedStringBuilder } from 'common/StringBuilder'; const EMPTY_HANDLERS: IApcHandler[] = []; @@ -153,13 +154,13 @@ export class ApcParser implements IApcParser { export class ApcHandler implements IApcHandler { private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT; - private _data = ''; + private _data = new LimitedStringBuilder(ApcHandler._payloadLimit); private _hitLimit: boolean = false; constructor(private _handler: (data: string) => boolean | Promise) { } public start(): void { - this._data = ''; + this._data.reset(); this._hitLimit = false; } @@ -167,9 +168,7 @@ export class ApcHandler implements IApcHandler { if (this._hitLimit) { return; } - this._data += utf32ToString(data, start, end); - if (this._data.length > ApcHandler._payloadLimit) { - this._data = ''; + if (this._data.append(utf32ToString(data, start, end))) { this._hitLimit = true; } } @@ -179,18 +178,18 @@ export class ApcHandler implements IApcHandler { if (this._hitLimit) { ret = false; } else if (success) { - ret = this._handler(this._data); + ret = this._handler(this._data.toString()); if (ret instanceof Promise) { // need to hold data until `ret` got resolved // dont care for errors, data will be freed anyway on next start return ret.then(res => { - this._data = ''; + this._data.reset(); this._hitLimit = false; return res; }); } } - this._data = ''; + this._data.reset(); this._hitLimit = false; return ret; } diff --git a/src/common/parser/DcsParser.ts b/src/common/parser/DcsParser.ts index 692200230..25c22b8dd 100644 --- a/src/common/parser/DcsParser.ts +++ b/src/common/parser/DcsParser.ts @@ -8,6 +8,7 @@ import { IDcsHandler, IParams, IHandlerCollection, IDcsParser, DcsFallbackHandle import { utf32ToString } from 'common/input/TextDecoder'; import { Params } from 'common/parser/Params'; import { ParserConstants } from 'common/parser/Constants'; +import { LimitedStringBuilder } from 'common/StringBuilder'; const EMPTY_HANDLERS: IDcsHandler[] = []; @@ -140,7 +141,7 @@ EMPTY_PARAMS.addParam(0); export class DcsHandler implements IDcsHandler { private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT; - private _data = ''; + private _data = new LimitedStringBuilder(DcsHandler._payloadLimit); private _params: IParams = EMPTY_PARAMS; private _hitLimit: boolean = false; @@ -152,7 +153,7 @@ export class DcsHandler implements IDcsHandler { // perf optimization: // clone only, if we have non empty params, otherwise stick with default this._params = (params.length > 1 || params.params[0]) ? params.clone() : EMPTY_PARAMS; - this._data = ''; + this._data.reset(); this._hitLimit = false; } @@ -160,9 +161,7 @@ export class DcsHandler implements IDcsHandler { if (this._hitLimit) { return; } - this._data += utf32ToString(data, start, end); - if (this._data.length > DcsHandler._payloadLimit) { - this._data = ''; + if (this._data.append(utf32ToString(data, start, end))) { this._hitLimit = true; } } @@ -172,20 +171,20 @@ export class DcsHandler implements IDcsHandler { if (this._hitLimit) { ret = false; } else if (success) { - ret = this._handler(this._data, this._params); + ret = this._handler(this._data.toString(), this._params); if (ret instanceof Promise) { // need to hold data and params until `ret` got resolved // dont care for errors, data will be freed anyway on next start return ret.then(res => { this._params = EMPTY_PARAMS; - this._data = ''; + this._data.reset(); this._hitLimit = false; return res; }); } } this._params = EMPTY_PARAMS; - this._data = ''; + this._data.reset(); this._hitLimit = false; return ret; } diff --git a/src/common/parser/OscParser.ts b/src/common/parser/OscParser.ts index 1f0c8cb23..3e05f2298 100644 --- a/src/common/parser/OscParser.ts +++ b/src/common/parser/OscParser.ts @@ -7,6 +7,7 @@ import { IOscHandler, IHandlerCollection, OscFallbackHandlerType, IOscParser, IS import { OscState, ParserConstants } from 'common/parser/Constants'; import { utf32ToString } from 'common/input/TextDecoder'; import { IDisposable } from 'common/Types'; +import { LimitedStringBuilder } from 'common/StringBuilder'; const EMPTY_HANDLERS: IOscHandler[] = []; @@ -194,13 +195,13 @@ export class OscParser implements IOscParser { export class OscHandler implements IOscHandler { private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT; - private _data = ''; + private _data = new LimitedStringBuilder(OscHandler._payloadLimit); private _hitLimit: boolean = false; constructor(private _handler: (data: string) => boolean | Promise) { } public start(): void { - this._data = ''; + this._data.reset(); this._hitLimit = false; } @@ -208,9 +209,7 @@ export class OscHandler implements IOscHandler { if (this._hitLimit) { return; } - this._data += utf32ToString(data, start, end); - if (this._data.length > OscHandler._payloadLimit) { - this._data = ''; + if (this._data.append(utf32ToString(data, start, end))) { this._hitLimit = true; } } @@ -220,18 +219,18 @@ export class OscHandler implements IOscHandler { if (this._hitLimit) { ret = false; } else if (success) { - ret = this._handler(this._data); + ret = this._handler(this._data.toString()); if (ret instanceof Promise) { // need to hold data until `ret` got resolved // dont care for errors, data will be freed anyway on next start return ret.then(res => { - this._data = ''; + this._data.reset(); this._hitLimit = false; return res; }); } } - this._data = ''; + this._data.reset(); this._hitLimit = false; return ret; }