mirror of
https://github.com/larvit/adf-codec.git
synced 2026-11-12 19:58:28 +01:00
Return the new code engine-limit where the engine runs short of memory or passes its longest array, so unsupported-node-shape stays deterministic
This commit is contained in:
+5
-1
@@ -9,6 +9,9 @@
|
|||||||
code. `MIGRATION.md` says which call to take.
|
code. `MIGRATION.md` says which call to take.
|
||||||
- **Breaking:** the markdown readers refuse a value that is not a string with the new code
|
- **Breaking:** the markdown readers refuse a value that is not a string with the new code
|
||||||
`not-a-string`; `MIGRATION.md` lists what each value did before.
|
`not-a-string`; `MIGRATION.md` lists what each value did before.
|
||||||
|
- **Breaking:** every call returns the new code `engine-limit` where the JavaScript engine runs
|
||||||
|
short of memory or passes its longest array, and, on JavaScriptCore, its longest string. A retry
|
||||||
|
may succeed. Every other refusal stays deterministic.
|
||||||
- **Breaking:** an attribute value that is not a plain object, array or JSON primitive, such as a
|
- **Breaking:** an attribute value that is not a plain object, array or JSON primitive, such as a
|
||||||
`Date`, `Map`, typed array or class instance, makes `isAdfDocument` return `false` and the
|
`Date`, `Map`, typed array or class instance, makes `isAdfDocument` return `false` and the
|
||||||
converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own
|
converters refuse it as `not-an-adf-document`. Before, the converters wrote it as `{}` or its own
|
||||||
@@ -90,7 +93,8 @@
|
|||||||
`losslessMarkdownToAdf` refuses it as `unsupported-node-shape`, where a few megabytes of nested
|
`losslessMarkdownToAdf` refuses it as `unsupported-node-shape`, where a few megabytes of nested
|
||||||
list lines ran the call out of memory.
|
list lines ran the call out of memory.
|
||||||
- Fix the markdown readers past the longest string or array the JavaScript engine builds: they
|
- Fix the markdown readers past the longest string or array the JavaScript engine builds: they
|
||||||
return `unsupported-node-shape` where they threw.
|
return `unsupported-node-shape` past V8's or SpiderMonkey's longest string, and `engine-limit`
|
||||||
|
past the rest, where they threw.
|
||||||
- Fix a paragraph of tens of millions of characters: `adfToLosslessMarkdown` and
|
- Fix a paragraph of tens of millions of characters: `adfToLosslessMarkdown` and
|
||||||
`adfToPortableMarkdown` write it in a few bytes of memory a character beyond the document and its
|
`adfToPortableMarkdown` write it in a few bytes of memory a character beyond the document and its
|
||||||
markdown, where they took about 50, so 80 million characters ran Node's default heap out of memory
|
markdown, where they took about 50, so 80 million characters ran Node's default heap out of memory
|
||||||
|
|||||||
+3
-2
@@ -88,8 +88,8 @@ Markdown the spelling table leaves alone, which `0.2.0` reads as a different doc
|
|||||||
### Error codes
|
### Error codes
|
||||||
|
|
||||||
`unmappable-image`, `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave
|
`unmappable-image`, `unspellable-character`, `unspellable-line-start` and `unspellable-link` leave
|
||||||
`ConvertErrorCode` and `not-a-string` joins it: a `switch` naming one of the four stops compiling,
|
`ConvertErrorCode` and `engine-limit` and `not-a-string` join it: a `switch` naming one of the four
|
||||||
and one naming every code needs the new case. A document `adfToMarkdown` refused with one of the
|
stops compiling, and one naming every code needs the new cases. A document `adfToMarkdown` refused with one of the
|
||||||
three `unspellable-` codes now converts in `adfToLosslessMarkdown`; markdown the parser refused
|
three `unspellable-` codes now converts in `adfToLosslessMarkdown`; markdown the parser refused
|
||||||
with one of the four now reads, or refuses with the code the table gives.
|
with one of the four now reads, or refuses with the code the table gives.
|
||||||
|
|
||||||
@@ -114,3 +114,4 @@ with one of the four now reads, or refuses with the code the table gives.
|
|||||||
| `null`, `undefined` or a non-empty array, on parse | throws a `TypeError` | `not-a-string` |
|
| `null`, `undefined` or a non-empty array, on parse | throws a `TypeError` | `not-a-string` |
|
||||||
| a `String` object, on parse | the document its text spells | `not-a-string`; pass `String(value)` |
|
| a `String` object, on parse | the document its text spells | `not-a-string`; pass `String(value)` |
|
||||||
| any other value that is not a string, such as a number or `{}`, on parse | an empty document | `not-a-string` |
|
| any other value that is not a string, such as a number or `{}`, on parse | an empty document | `not-a-string` |
|
||||||
|
| a conversion that runs the JavaScript engine short of memory or past its longest array | throws a `RangeError`, or the engine ends the process | `engine-limit`; retry it once |
|
||||||
|
|||||||
@@ -210,11 +210,11 @@ Each call's name says which markdown it reads or writes:
|
|||||||
|
|
||||||
| Call | Markdown | Refuses with |
|
| Call | Markdown | Refuses with |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
| `adfToLosslessMarkdown` | writes the lossless flavour: CommonMark plus GFM's pipe tables and `~~`, with `!adf:` directives and `adf:` fences for everything else | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||||
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
| `adfToPortableMarkdown` | writes markdown GitHub, GitLab and Obsidian render; drops what they cannot show, and writes no directive | `engine-limit`, `not-an-adf-document`, `unsupported-document-version`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||||
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
| `commonMarkToAdf` | reads strict CommonMark as its spec says: a directive, a pipe table, `~~`, `==` and an `adf:` fence read as text and code | `engine-limit`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||||
| `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` |
|
| `losslessMarkdownToAdf` | reads the lossless flavour, markdown `adfToLosslessMarkdown` wrote, edited or not | every code in [The errors](#the-errors) except `not-an-adf-document` and `unsupported-document-version` |
|
||||||
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
| `portableMarkdownToAdf` | reads CommonMark, GFM's pipe tables and `~~`, alerts and task markers, Obsidian's callouts and `==`; a directive and an `adf:` fence read as text and code | `engine-limit`, `malformed-pipe-table`, `not-a-string`, `unmappable-html`, `unsupported-nesting-depth`, `unsupported-node-shape` |
|
||||||
|
|
||||||
`isAdfDocument` is the type guard for the boundary you receive JSON at. It checks the shape the
|
`isAdfDocument` is the type guard for the boundary you receive JSON at. It checks the shape the
|
||||||
converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding
|
converters require: `type: 'doc'`, a finite `version` (the converters take only 1), nodes holding
|
||||||
@@ -410,9 +410,8 @@ typed on `ConvertError` takes both.
|
|||||||
|
|
||||||
A call reports the first refusal in document order and stops, so a document with several surfaces
|
A call reports the first refusal in document order and stops, so a document with several surfaces
|
||||||
them one per call. Every refusal is deterministic — there is no I/O anywhere — so a retry returns
|
them one per call. Every refusal is deterministic — there is no I/O anywhere — so a retry returns
|
||||||
the identical error: fix the input, or set the document aside. The exception is memory running
|
the identical error: fix the input, or set the document aside. The exception is `engine-limit`,
|
||||||
short, which returns `unsupported-node-shape` at the empty path, the code and path of markdown past
|
whose causes include memory running short: retry it once, and treat a repeat as lasting.
|
||||||
the length bound. Retry once any call that returns that; a repeat is a lasting refusal.
|
|
||||||
|
|
||||||
### Parsing
|
### Parsing
|
||||||
|
|
||||||
@@ -444,8 +443,9 @@ The emitter raises these, and so does a parse, which asks the emitter how to spe
|
|||||||
|
|
||||||
| Code | Fires when | What you can do |
|
| Code | Fires when | What you can do |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `engine-limit` | in every call: a conversion that passes the longest array the JavaScript engine builds or runs it short of memory, and on JavaScriptCore (Bun, Safari) one that passes its longest string, 2^31 − 1 characters | retry once; if it repeats, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
|
||||||
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
| `unsupported-nesting-depth` | blocks, marks, an attribute's JSON or a carried node's JSON nest past 500 levels | keep the ADF and pass the document over, or show it read-only; flatten the input where you are the one who wrote it |
|
||||||
| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or one that, written out as plain CommonMark, would be longer than 536870888 characters, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting, in `adfToLosslessMarkdown`: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536870888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes the longest string or array the JavaScript engine builds, or runs it short of memory | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a portable line or task list, report it as a bug. Past 536870888 characters or the engine's limit, retry once; if it repeats, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
|
| `unsupported-node-shape` | parsing, in `losslessMarkdownToAdf`: markdown spells a node with an attribute, value, argument or body its type does not take, or without one it needs, wraps a directive mark around nothing or only hard breaks, writes as a directive a node or mark the lossless flavour spells as CommonMark, or one that, written out as plain CommonMark, would be longer than 536870888 characters, or puts a reserved directive out of place: `!adf:textBreak{}` parting nothing, `!adf:doc` anywhere but as the whole document. Emitting, in `adfToLosslessMarkdown`: a node not riding the carry that carries one mark type twice, a block or a non-text inline node holding `text`, or a leaf block or a non-text inline node holding `content`. Emitting, in `adfToPortableMarkdown`: a line or task list the portable writer cannot spell, a bug in this library. Emitting, in both writers: a document whose markdown would be longer than 536870888 characters, V8's longest string, on every engine; in `adfToPortableMarkdown`, also a document whose text alone is that long, edge whitespace included. In every call: a conversion that passes the longest string the JavaScript engine builds | fix what the message names; [`spec/flavour.md`](https://github.com/larvit/adf-codec/blob/main/spec/flavour.md) lists every type's attributes and body. For a portable line or task list, report it as a bug. Past 536870888 characters or the engine's longest string, writing: set the document aside, keeping the ADF and passing it over or showing it read-only; reading: shorten the markdown, or nest its lists and quotes less deeply |
|
||||||
|
|
||||||
## The guarantees
|
## The guarantees
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -467,6 +467,11 @@ handles one cause alike whichever node, attribute or direction raised it.
|
|||||||
causes the bound (§The code list). A carried node passes it on about 1 MB of deeply nested JSON,
|
causes the bound (§The code list). A carried node passes it on about 1 MB of deeply nested JSON,
|
||||||
since the block carry indents two spaces a level; `todo.md` item 107 respells the carry in a
|
since the block carry indents two spaces a level; `todo.md` item 107 respells the carry in a
|
||||||
length linear in its JSON (2026-10-11).
|
length linear in its JSON (2026-10-11).
|
||||||
|
- A conversion the engine stops, by running short of memory or passing its longest array, is
|
||||||
|
`engine-limit`, at the empty path. A retry may pass it, so `unsupported-node-shape`, which never
|
||||||
|
does, cannot hold it. JavaScriptCore throws the same `Out of memory` past its longest string,
|
||||||
|
which takes `engine-limit` too: telling the two apart would mean reading the engine's message
|
||||||
|
(2026-10-13, the maintainer).
|
||||||
- The markdown readers and `htmlToAdf` refuse a value that is not a string as `not-a-string`, at
|
- The markdown readers and `htmlToAdf` refuse a value that is not a string as `not-a-string`, at
|
||||||
position `{ line: 1, offset: 0 }` with an empty path: no existing code reads true of it, and
|
position `{ line: 1, offset: 0 }` with an empty path: no existing code reads true of it, and
|
||||||
`not-an-adf-document` names the emitters' input (2026-10-05).
|
`not-an-adf-document` names the emitters' input (2026-10-05).
|
||||||
@@ -682,7 +687,8 @@ carry's two-space JSON, which indents every value its depth over, stops at the b
|
|||||||
|
|
||||||
JavaScriptCore builds a joined block up to its own longest string, 2^31 − 1, before the check. Past
|
JavaScriptCore builds a joined block up to its own longest string, 2^31 − 1, before the check. Past
|
||||||
that, JavaScriptCore throws `Out of memory`, as it does when its heap runs short. That throw, and
|
that, JavaScriptCore throws `Out of memory`, as it does when its heap runs short. That throw, and
|
||||||
any engine's throw past its longest array, is refused with the engine's message.
|
any engine's throw past its longest array or out of memory, is `engine-limit` (§Which code a cause
|
||||||
|
takes).
|
||||||
|
|
||||||
## Nothing recurses unbounded
|
## Nothing recurses unbounded
|
||||||
|
|
||||||
|
|||||||
@@ -23,15 +23,19 @@ test('passes a text within the bound', () => {
|
|||||||
assert.deepEqual(withinLongest('x'), success('x'))
|
assert.deepEqual(withinLongest('x'), success('x'))
|
||||||
})
|
})
|
||||||
|
|
||||||
test('refuses a string past the bound as too long on every engine, and names any other engine limit', () => {
|
test('refuses a string past the bound as too long on every engine, and any other engine limit as engine-limit', () => {
|
||||||
assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Invalid string length')), tooLong())
|
assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Invalid string length')), tooLong())
|
||||||
assert.deepEqual(withinEngineLimits(thrown('InternalError', 'allocation size overflow')), tooLong())
|
assert.deepEqual(withinEngineLimits(thrown('InternalError', 'allocation size overflow')), tooLong())
|
||||||
assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Out of memory')), {
|
assert.deepEqual(withinEngineLimits(thrown('RangeError', 'Out of memory')), {
|
||||||
error: { code: 'unsupported-node-shape', message: 'the conversion passed this JavaScript engine\'s longest string or array, or ran it out of memory (Out of memory)', path: [] },
|
error: { code: 'engine-limit', message: 'the conversion passed this JavaScript engine\'s longest string or array, or ran it out of memory (Out of memory)', path: [] },
|
||||||
ok: false,
|
ok: false,
|
||||||
})
|
})
|
||||||
assert.equal(withinEngineLimits(thrown('RangeError', 'Invalid array length')).ok, false)
|
const code = (message: string): string | undefined => {
|
||||||
assert.equal(withinEngineLimits(thrown('RangeError', 'Array buffer allocation failed')).ok, false)
|
const refused = withinEngineLimits(thrown('RangeError', message))
|
||||||
|
return refused.ok ? undefined : refused.error.code
|
||||||
|
}
|
||||||
|
assert.equal(code('Invalid array length'), 'engine-limit')
|
||||||
|
assert.equal(code('Array buffer allocation failed'), 'engine-limit')
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
withinEngineLimits(() => success('x')),
|
withinEngineLimits(() => success('x')),
|
||||||
{ ok: true, value: 'x' },
|
{ ok: true, value: 'x' },
|
||||||
|
|||||||
@@ -36,6 +36,6 @@ export function withinEngineLimits<T>(convert: () => Result<T>): Result<T> {
|
|||||||
if (pastLongestString.includes(thrown)) return tooLong()
|
if (pastLongestString.includes(thrown)) return tooLong()
|
||||||
// A stack overflow is a bug (docs/decisions.md §Nothing recurses unbounded), so it stays a throw.
|
// A stack overflow is a bug (docs/decisions.md §Nothing recurses unbounded), so it stays a throw.
|
||||||
if (!pastEngineLimit.includes(thrown)) throw error
|
if (!pastEngineLimit.includes(thrown)) throw error
|
||||||
return failure('unsupported-node-shape', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${error.message})`, [])
|
return failure('engine-limit', `the conversion passed this JavaScript engine's longest string or array, or ran it out of memory (${error.message})`, [])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export type ConvertErrorCode =
|
export type ConvertErrorCode =
|
||||||
|
| 'engine-limit'
|
||||||
| 'malformed-directive'
|
| 'malformed-directive'
|
||||||
| 'malformed-pipe-table'
|
| 'malformed-pipe-table'
|
||||||
| 'not-a-string'
|
| 'not-a-string'
|
||||||
|
|||||||
Reference in New Issue
Block a user