From 1b7ee4316b12283a5d2c1a24ba0adb412e555971 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:45:14 +0200 Subject: [PATCH 1/5] Latch terminal overflow from lifetime counter retirement as well --- Sources/JSON/Limits/JSONWorkMeter.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/JSON/Limits/JSONWorkMeter.swift b/Sources/JSON/Limits/JSONWorkMeter.swift index d4b81a7..a0a5360 100644 --- a/Sources/JSON/Limits/JSONWorkMeter.swift +++ b/Sources/JSON/Limits/JSONWorkMeter.swift @@ -41,9 +41,11 @@ package struct JSONWorkMeter: ~Copyable { health.observed[metric.index] = max(health.observed[metric.index], attempted) } private func violation(_ metric: JSONResourceMetric, _ attempted: UInt64?, overflow: Bool = false) -> JSONFailure { - JSONFailure(.resource, code: overflow ? .counterOverflow : .limitExceeded, + let failure = JSONFailure(.resource, code: overflow ? .counterOverflow : .limitExceeded, resource: .init(domain: domain, metric: metric, limit: limits[metric], attemptedUsage: attempted, overflow: overflow)) + if health.failure == nil { health.failure = failure } + return failure } package func adding(_ metric: JSONResourceMetric, _ current: UInt64, _ amount: UInt64) throws -> UInt64 { let (next, overflow) = current.addingReportingOverflow(amount) From 8b55bd2d55fdb61034665022b2c60d1c389e81ca Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:46:23 +0200 Subject: [PATCH 2/5] Prove caught retired-counter overflow cannot resume the ledger --- Tests/JSONTests/Release1_1/ReadAccessTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Tests/JSONTests/Release1_1/ReadAccessTests.swift b/Tests/JSONTests/Release1_1/ReadAccessTests.swift index 7c609df..9908c75 100644 --- a/Tests/JSONTests/Release1_1/ReadAccessTests.swift +++ b/Tests/JSONTests/Release1_1/ReadAccessTests.swift @@ -107,6 +107,13 @@ private func expectAccess(_ path: [JSONLocationComponent], _ code: JSONFailure.C } @Test func readAccessSharedProjectionLimitsAndTerminalAccounting() throws { + var huge = JSONLimits.balanced; huge[.inputBytes] = UInt64(Int.max) - 1 + var lifetime = try JSONWorkMeter(limits: huge, domain: .core) + for _ in 0..<2 { try lifetime.charge(.inputBytes, huge[.inputBytes]); try lifetime.resetValueBounds() } + #expect(throws: JSONFailure.self) { try lifetime.charge(.inputBytes, huge[.inputBytes]) } + let terminal = lifetime.statistics()[.inputBytes] + #expect(throws: JSONFailure.self) { try lifetime.checkpoint() } + #expect(lifetime.statistics()[.inputBytes] == terminal) let model = ReadModel(count: .max, name: "e\u{301}", bytes: Data([0, 1, 255])) let value = try JSON(encoding: model) #expect(try value.decode(ReadModel.self) == model) From dc4dd42e44433741de9fd42caf78ad616d192a79 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:41:40 +0200 Subject: [PATCH 3/5] Record disposition for every index benchmark review trigger --- .../Release1.1/IndexMeasurements.md | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Documentation/Architecture/Release1.1/IndexMeasurements.md b/Documentation/Architecture/Release1.1/IndexMeasurements.md index ebbceb1..e741757 100644 --- a/Documentation/Architecture/Release1.1/IndexMeasurements.md +++ b/Documentation/Architecture/Release1.1/IndexMeasurements.md @@ -50,3 +50,44 @@ work; the final serialized pair is used above. All actual failure/command record remain evidence. These stage results precede the reader's shared-meter and ISO correction; affected integrated performance must be refreshed on the final source. The historical approximately 11% streaming disposition remains separate. + +## Complete trigger review + +All 11 construction and 16 lookup cells above 10% are retained below. +Construction absolute increases are at most 1.541 microseconds per operation; +lookup increases are at most 1.625 microseconds per twenty queries. These are +observed regressions, without a statistical noise or causal attribution claim. +Accept the small-object metadata/ownership cost for the measured wide-object +benefit, retaining the scalar lookup path below 32. Empty measurements establish +no throughput claim. Four predecessor refusals remain excluded from timing ratios. +The threshold stays 10%; mobile crossovers remain unqualified. + +| Operation / shape / width / query | Before / after microseconds | Change | Disposition | +| --- | ---: | ---: | --- | +| accumulator / prefix / 0 / — | 0.584 / 0.666 | +14.04% | No throughput claim; retain observation | +| checked / prefix / 0 / — | 0.792 / 0.875 | +10.48% | No throughput claim; retain observation | +| checked / unicode / 0 / — | 0.709 / 0.833 | +17.49% | No throughput claim; retain observation | +| checked / ascii / 1 / — | 1.125 / 1.417 | +25.96% | Accept measured small-case tradeoff | +| native-keyed / unicode / 0 / — | 2.125 / 2.416 | +13.69% | No throughput claim; retain observation | +| native-keyed / ascii / 1 / — | 4.041 / 4.542 | +12.40% | Accept measured small-case tradeoff | +| native-keyed / unicode / 1 / — | 3.667 / 4.042 | +10.23% | Accept measured small-case tradeoff | +| parse / ascii / 1 / — | 2.625 / 2.958 | +12.69% | Accept measured small-case tradeoff | +| parse / prefix / 1 / — | 11.792 / 13.333 | +13.07% | Accept measured small-case tradeoff | +| parse / unicode / 1 / — | 2.542 / 2.834 | +11.49% | Accept measured small-case tradeoff | +| parse / unicode / 4 / — | 6.500 / 7.500 | +15.38% | Accept measured small-case tradeoff | +| lookup / ascii / 0 / miss | 0.083 / 0.167 | +101.20% | No throughput claim; retain observation | +| lookup / prefix / 0 / miss | 0.042 / 0.167 | +297.62% | No throughput claim; retain observation | +| lookup / unicode / 0 / miss | 0.042 / 0.166 | +295.24% | No throughput claim; retain observation | +| lookup / ascii / 1 / miss | 0.625 / 0.875 | +40.00% | Accept measured small-case tradeoff | +| lookup / unicode / 1 / first | 3.542 / 3.916 | +10.56% | Accept measured small-case tradeoff | +| lookup / unicode / 1 / last | 3.542 / 3.916 | +10.56% | Accept measured small-case tradeoff | +| lookup / unicode / 1 / middle | 3.542 / 3.916 | +10.56% | Accept measured small-case tradeoff | +| lookup / unicode / 1 / miss | 0.583 / 0.875 | +50.09% | Accept measured small-case tradeoff | +| lookup / ascii / 4 / first | 2.875 / 3.208 | +11.58% | Accept measured small-case tradeoff | +| lookup / ascii / 4 / miss | 2.334 / 2.834 | +21.42% | Accept measured small-case tradeoff | +| lookup / unicode / 4 / miss | 2.333 / 2.833 | +21.43% | Accept measured small-case tradeoff | +| lookup / ascii / 8 / miss | 4.542 / 5.292 | +16.51% | Accept measured small-case tradeoff | +| lookup / unicode / 8 / miss | 4.542 / 5.292 | +16.51% | Accept measured small-case tradeoff | +| lookup / ascii / 16 / first | 2.916 / 3.208 | +10.01% | Accept measured small-case tradeoff | +| lookup / ascii / 16 / miss | 8.625 / 10.250 | +18.84% | Accept measured small-case tradeoff | +| lookup / unicode / 16 / miss | 8.916 / 9.959 | +11.70% | Accept measured small-case tradeoff | From bc1c64d09e1296ce9a52dce2a8a32ce165b31079 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:53:35 +0200 Subject: [PATCH 4/5] Add immutable exact Version 1 Codable payload with shared native budgets --- Documentation/Architecture/API.md | 22 +++ .../Architecture/Release1.1/Persistence.md | 44 +++++ Sources/JSON/Coding/JSONBase64.swift | 4 +- Sources/JSON/Coding/JSONCodablePayload.swift | 158 ++++++++++++++++++ .../Documentation.docc/ExactPersistence.md | 40 +++++ Sources/JSON/Documentation.docc/JSON.md | 1 + Tests/JSONTests/Release1_1/PayloadTests.swift | 153 +++++++++++++++++ 7 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 Documentation/Architecture/Release1.1/Persistence.md create mode 100644 Sources/JSON/Coding/JSONCodablePayload.swift create mode 100644 Sources/JSON/Documentation.docc/ExactPersistence.md create mode 100644 Tests/JSONTests/Release1_1/PayloadTests.swift diff --git a/Documentation/Architecture/API.md b/Documentation/Architecture/API.md index 453c05e..05343bb 100644 --- a/Documentation/Architecture/API.md +++ b/Documentation/Architecture/API.md @@ -168,3 +168,25 @@ subsystem ceilings independently. WorkUnits includes subsystem work, so subtract once. Failure/cancellation terminates the composition because no successful report accounts for failed work. The [actual consumer interface](../ConsumerInterface.md) and runnable consumer demonstrate this contract. + +## Additive 1.1 candidate + +JSON and JSONDocument provide `read(limits:_:)` and `readWithReport(limits:_:)` +with synchronous non-Sendable JSONReadCursor. Throwing String/JSONKey/Int brackets, +required kind/integer access, explicit Double/Decimal policies, raw/scalar/model +presence and native projection share one active meter. Paths are typed and bounded; +JSONAccessFailure carries the complete path and structured cause. Scope exit +invalidates handles and releases root/source ownership. Resource/cancellation +failures remain terminal when caught by a caller. See [reading](Release1.1/Reading.md). + +JSON `init(encoding:coding:)`, `decode(_:coding:)` and `decodeWithReport(_:coding:)` +delegate to existing native facades. JSONCodablePayload provides +`init(preserving:limits:)`, `preservingWithReport(_:limits:)`, `value(limits:)` +and `valueWithReport(limits:)`. Its explicit Codable Version 1 stores validated +UTF-8 only and shares native parent budgets. See [persistence](Release1.1/Persistence.md). + +The native ISO-8601 Date strategy now includes calendar fields as well as fractional +UTC time. Prior time-only stored values cannot recover their original calendar +date; application migration needs an independent source of that information. +Noncanonical Base64 with nonzero unused padding bits now rejects. These corrections +restore the documented strict/native contracts; they do not add a permissive mode. diff --git a/Documentation/Architecture/Release1.1/Persistence.md b/Documentation/Architecture/Release1.1/Persistence.md new file mode 100644 index 0000000..2f40307 --- /dev/null +++ b/Documentation/Architecture/Release1.1/Persistence.md @@ -0,0 +1,44 @@ +# Exact Version 1 persistence + +The implemented public wrapper is immutable `JSONCodablePayload: Sendable, Codable`. +`init(preserving:limits:)`, `preservingWithReport(_:limits:)`, `value(limits:)` +and `valueWithReport(limits:)` use the existing owned codec. Private storage is +validated UTF-8 Data only, without a DOM cache, public byte mutation or equality. + +Version 1 requires format `swift-json-value`, integer version 1 and strict standard +Base64 `utf8Base64`. Compact insertion-order writing preserves admitted number +lexemes, with normalized fallback. Scalar sequences, exact decimals and array/member +order survive fixed-primitive foreign transport. Whitespace/escape/source-document +fidelity is outside this contract. Eight committed golden fixtures pin the envelope +and inner hashes; empty arrays/objects are additional behavior cases. + +Construction preflights prospective Base64, fixed envelope output and text sizes. +Explicit limits intersect balanced, never widen it. Decode admits text before +decoding, checks decoded size, certifies complete inner grammar/EOF and releases the +temporary DOM. Opening is a fresh bounded parse. Copies, simultaneous byte/text/DOM +ownership, work and cancellation share the enclosing native meter when nested. +Foreign callbacks/allocations remain caller work. No meter is borrowed across an +Encoder/Decoder callback. Logical retention is not an allocator or RSS promise. + +Required field types use ordinary Codable errors. Unknown marker/version and +malformed inner syntax/Base64 map to bounded corruption with a static description; +resource/configuration failures and cancellation retain their types. A caller that +catches an owned operational failure cannot certify successful native completion. +The shared Base64 decoder now rejects nonzero unused padding bits, closing a real +strict-codec gap. No legacy heuristic or corrupt-data-to-null fallback is added. + +`Tools/MobileQualification` imports the four public products and SwiftData outside +the core package. Three Swift Testing cases qualify public behavior, stream/sink +completion/cancellation and disk persistence. The disk case saves eight exact +fixtures, executes each save/reopen/update phase in a separate process, reopens at the +same URL, checks scalar bytes/order/lexemes, queries/sorts ordinary fields, updates, +releases again and verifies a third reopen. Optional nil and present JSON null are +distinct. SwiftData owns its database representation; embedded JSON predicates and +cloud synchronization are not claimed. Mac, simulator and physical-device outcomes +are separate receipts, and missing device evidence blocks the full R5 gate. + +The first simulator release run failed the additional weak-container assertion: +SwiftData retained a container beyond lexical scope. The debug Mac run passed it. +Retain both outcomes; do not claim that SDK lifetime is controlled by this package. +The final disk harness uses three independently exited processes on Mac/simulators, +so reopening cannot read prior model/context/container memory or SDK caches. diff --git a/Sources/JSON/Coding/JSONBase64.swift b/Sources/JSON/Coding/JSONBase64.swift index a0e5e21..9124c98 100644 --- a/Sources/JSON/Coding/JSONBase64.swift +++ b/Sources/JSON/Coding/JSONBase64.swift @@ -40,11 +40,11 @@ package enum JSONBase64 { guard let b = iterator.next(),let c = iterator.next(),let d = iterator.next(),let x = value(a),let y = value(b) else { throw malformed() } try emit(x << 2 | y >> 4,meter: &meter) if c == 61 { - guard d == 61, iterator.next() == nil else { throw malformed() }; break + guard d == 61, y & 15 == 0, iterator.next() == nil else { throw malformed() }; break } guard let z = value(c) else { throw malformed() } try emit(y << 4 | z >> 2,meter: &meter) - if d == 61 { guard iterator.next() == nil else { throw malformed() }; break } + if d == 61 { guard z & 3 == 0, iterator.next() == nil else { throw malformed() }; break } guard let w = value(d) else { throw malformed() } try emit(z << 6 | w,meter: &meter) } diff --git a/Sources/JSON/Coding/JSONCodablePayload.swift b/Sources/JSON/Coding/JSONCodablePayload.swift new file mode 100644 index 0000000..476a94b --- /dev/null +++ b/Sources/JSON/Coding/JSONCodablePayload.swift @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation + +/// Exact JSON value persistence through a fixed, versioned Codable envelope. +/// +/// Version 1 carries `format: "swift-json-value"`, integer `version: 1` and +/// `utf8Base64`: strict standard Base64 of one complete strict UTF-8 document. +/// The inner writer preserves member order, scalar identity and admitted number +/// lexemes. Whitespace, escape spelling and source documents are not persisted. +/// Only validated bytes are stored; opening parses afresh without a hidden DOM. +/// Automatic coding uses finite balanced limits. Foreign coder work is caller +/// work; nesting in the native coder shares its cumulative meter. +public struct JSONCodablePayload: Sendable, Codable { + private let utf8: Data + private enum Keys: String, CodingKey { case format, version, utf8Base64 } + private init(validated bytes: Data) { utf8 = bytes } + + /// Writes an exact value with Version 1's compact, insertion-order policy. + /// - Parameters: + /// - value: The admitted JSON value, including a present JSON null. + /// - limits: Finite ceilings intersected with the balanced persistence profile. + /// - Throws: Resource/configuration failure or cancellation; no partial payload. + public init(preserving value: JSON, limits: JSONLimits = .balanced) throws { + self = try Self.preservingWithReport(value, limits: limits).result + } + /// Creates a payload and reports owned writing, copies and prospective envelope checks. + /// - Parameters: + /// - value: The exact JSON value to preserve. + /// - limits: Tightening ceilings; a larger profile cannot widen automatic persistence. + /// - Throws: Resource/configuration failure or cancellation. + public static func preservingWithReport(_ value: JSON, limits: JSONLimits = .balanced) throws -> JSONOperationReport { + var meter = try JSONWorkMeter(limits: JSONLimits.balanced.intersection(limits), domain: .coding) + let bytes = try JSONWriter(options: .init(numberStyle: .preserved)).write(value, meter: &meter) + try envelopeCapacity(UInt64(bytes.count), meter: &meter) + for _ in bytes { try meter.work(); try meter.retain(1) } + let data = Data(bytes) + try meter.release(UInt64(bytes.count)); try meter.checkpoint() + return .init(result: Self(validated: data), statistics: meter.statistics()) + } + /// Opens a validated payload through a new complete strict parse operation. + /// - Parameter limits: Finite ceilings intersected with the balanced profile. + /// - Throws: Resource/configuration failure or cancellation. + public func value(limits: JSONLimits = .balanced) throws -> JSON { try valueWithReport(limits: limits).result } + /// Parses afresh and reports owned stored-byte admission, copying and parsing. + /// - Parameter limits: Tightening ceilings; stored data never selects limits. + /// - Throws: Resource/configuration failure or cancellation. + public func valueWithReport(limits: JSONLimits = .balanced) throws -> JSONOperationReport { + var meter = try JSONWorkMeter(limits: JSONLimits.balanced.intersection(limits), domain: .parsing) + try Self.admit(utf8, meter: &meter) + let value = try Self.parse(utf8, meter: &meter) + try meter.release(UInt64(utf8.count)); try meter.checkpoint() + return .init(result: value, statistics: meter.statistics()) + } + private static func admit(_ bytes: Data, meter: inout JSONWorkMeter) throws { + try meter.require(.inputBytes, UInt64(bytes.count)) + for _ in bytes { try meter.work(); try meter.retain(1) } + } + private static func parse(_ bytes: Data, meter: inout JSONWorkMeter) throws -> JSON { + var input: [UInt8] = [] + for byte in bytes { try meter.work(); try meter.retain(1); input.append(byte) } + let result = try JSONParser().parse(input, meter: &meter) + try meter.release(UInt64(input.count)); return result + } + private static func envelopeCapacity(_ length: UInt64, meter: inout JSONWorkMeter) throws { + try meter.require(.inputBytes, length) + let rounded = try meter.adding(.outputBytes, length, 2) / 3 + let (base64, overflow) = rounded.multipliedReportingOverflow(by: 4) + if overflow { _ = try meter.adding(.outputBytes, UInt64.max, 1) } + try meter.require(.stringBytes, base64) + // Fixed primitive fields in their shortest compact JSON spelling. + let overhead = UInt64(#"{"format":"swift-json-value","version":1,"utf8Base64":""}"#.utf8.count) + _ = try meter.adding(.outputBytes, base64, overhead) + let text = try meter.adding(.aggregateTextBytes, base64, 39) + _ = try meter.adding(.aggregateTextBytes, meter.used(.aggregateTextBytes), text) + } + private func encoded(meter: inout JSONWorkMeter) throws -> (String, UInt64) { + let before = meter.used(.retainedBytes) + do { + try Self.admit(utf8, meter: &meter) + try Self.envelopeCapacity(UInt64(utf8.count), meter: &meter) + let (text, count) = try JSONBase64.encode(utf8, meter: &meter) + try meter.charge(.aggregateTextBytes, count) + try meter.release(UInt64(utf8.count)) + return (text, count) + } catch { try meter.release(meter.used(.retainedBytes) - before); throw error } + } + /// Encodes Version 1 primitive fields; it never invokes foreign numeric/key coding for the inner JSON. + /// - Parameter encoder: The enclosing coder, whose callbacks are outside owned work caps. + /// - Throws: Coder errors, resource/configuration failure or cancellation. + public func encode(to encoder: any Encoder) throws { + let native = encoder as? JSONNativeEncoder + if let native { + try native.state.meter.restrict(to: .balanced) + let (text, reservation) = try encoded(meter: &native.state.meter) + do { try encodeFields(text, to: encoder); try native.state.check() } + catch { try native.state.meter.release(reservation); throw error } + try native.state.meter.release(reservation) + } else { + var local = try JSONWorkMeter(limits: .balanced, domain: .coding) + let (text, reservation) = try encoded(meter: &local) + do { try encodeFields(text, to: encoder); try local.checkpoint() } + catch { try local.release(reservation); throw error } + try local.release(reservation) + } + } + private func encodeFields(_ text: String, to encoder: any Encoder) throws { + // Do not keep an inout meter borrow across arbitrary Encoder callbacks. + var container = encoder.container(keyedBy: Keys.self) + try container.encode("swift-json-value", forKey: .format) + try container.encode(1, forKey: .version) + try container.encode(text, forKey: .utf8Base64) + } + private static func validated(_ marker: String, version: Int, text: String, meter: inout JSONWorkMeter) throws -> Data { + let before = meter.used(.retainedBytes) + do { + try JSONAdmission.text(marker, meter: &meter, owned: true) + try JSONAdmission.text(text, meter: &meter, owned: true) + guard marker == "swift-json-value", version == 1 else { throw JSONFailure(.syntax, code: .invalidSyntax) } + var count: UInt64 = 0 + for _ in text.utf8 { try meter.work(); count = try meter.adding(.stringBytes, count, 1) } + guard count % 4 == 0 else { throw JSONFailure(.syntax, code: .invalidSyntax) } + // Maximum decoded size is checked before Base64's first output byte. + let (maximum, overflow) = (count / 4).multipliedReportingOverflow(by: 3) + if overflow { _ = try meter.adding(.inputBytes, UInt64.max, 1) } + // Padding may reduce the inclusive decoded capacity by up to two. + var padding: UInt64 = 0 + for byte in text.utf8.suffix(2).reversed() { try meter.work(); if byte == 61 { padding += 1 } else { break } } + guard maximum >= padding else { throw JSONFailure(.syntax, code: .invalidSyntax) } + try envelopeCapacity(maximum - padding, meter: &meter) + let bytes = try JSONBase64.decode(text, meter: &meter) + let beforeDOM = meter.used(.retainedBytes) + _ = try parse(bytes, meter: &meter) // Certifies trailing syntax/whitespace/EOF. + try meter.release(meter.used(.retainedBytes) - beforeDOM) + try meter.release(meter.used(.retainedBytes) - before - UInt64(bytes.count)) + try meter.checkpoint(); return bytes + } catch { try meter.release(meter.used(.retainedBytes) - before); throw error } + } + /// Validates Version 1, strict Base64 and the complete inner document before storing only its bytes. + /// - Parameter decoder: The enclosing coder supplying fixed primitive fields. + /// - Throws: Ordinary Codable field errors or bounded corruption; operational errors retain their types. + public init(from decoder: any Decoder) throws { + let native = decoder as? JSONNativeDecoder + if let native { try native.state.meter.restrict(to: .balanced) } + let container = try decoder.container(keyedBy: Keys.self) + let marker = try container.decode(String.self, forKey: .format) + let version = try container.decode(Int.self, forKey: .version) + let text = try container.decode(String.self, forKey: .utf8Base64) + do { + if let native { utf8 = try Self.validated(marker, version: version, text: text, meter: &native.state.meter) } + else { + var local = try JSONWorkMeter(limits: .balanced, domain: .coding) + utf8 = try Self.validated(marker, version: version, text: text, meter: &local) + } + } catch let failure as JSONFailure where failure.category == .syntax { + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Invalid Swift-JSON Version 1 payload", underlyingError: failure)) + } + } +} diff --git a/Sources/JSON/Documentation.docc/ExactPersistence.md b/Sources/JSON/Documentation.docc/ExactPersistence.md new file mode 100644 index 0000000..a62ccec --- /dev/null +++ b/Sources/JSON/Documentation.docc/ExactPersistence.md @@ -0,0 +1,40 @@ +# Exact persistence + +Transport exact JSON through a foreign Codable implementation using +``JSONCodablePayload``. The fixed Version 1 envelope carries the ASCII marker +`swift-json-value`, integer version `1` and strict standard Base64 in `utf8Base64`. +The inner strict UTF-8 document keeps decoded scalar sequences, member and array +order, exact decimal meaning and admitted numeric lexemes. Its compact writer +does not preserve source whitespace, escape spelling, labels or source maps. + +Construction/opening use finite balanced limits; explicit limits can tighten +them. Automatic Codable also uses balanced limits. Native nesting shares the +enclosing cumulative meter. Foreign coder allocations and callbacks remain caller +work. Logical bytes include overlapping owned buffers and temporary DOMs, and +are distinct from process RSS. A decoded wrapper stores only validated UTF-8; +``JSONCodablePayload/value(limits:)`` performs a new complete parse. + +Required missing/wrong field types use ordinary Codable errors. An unsupported +marker/version, malformed Base64 or invalid inner document produces bounded +`DecodingError.dataCorrupted`. Resource/configuration errors and cancellation +retain their types. Corruption never becomes a null value. + +SwiftData belongs in an application or example target importing SwiftData. +Use `@Attribute(.codable)` for the wrapper, with an independent application schema +version. An optional attribute of `nil` differs from a present wrapper preserving +JSON null. Query and sort ordinary model fields; this example establishes no +queryability of the embedded JSON. Persist/save, release the model/context/container +and recreate the container at the same disk URL to test reopening. + +Package version, payload format version, application data schema and SwiftData +model schema are separate. Version 1 has no heuristic legacy detection or silent +upgrade. Applications migrating bare JSON/Data must explicitly validate the +conversion and retain the original data according to their own migration policy. + +## Topics + +### Explicit wrapper + +- ``JSONCodablePayload`` +- ``JSONCodablePayload/preservingWithReport(_:limits:)`` +- ``JSONCodablePayload/valueWithReport(limits:)`` diff --git a/Sources/JSON/Documentation.docc/JSON.md b/Sources/JSON/Documentation.docc/JSON.md index f5b82b5..bac4608 100644 --- a/Sources/JSON/Documentation.docc/JSON.md +++ b/Sources/JSON/Documentation.docc/JSON.md @@ -54,6 +54,7 @@ belongs exclusively to each invocation. ### Guides - +- ### Exact model - ``JSON/JSON`` diff --git a/Tests/JSONTests/Release1_1/PayloadTests.swift b/Tests/JSONTests/Release1_1/PayloadTests.swift new file mode 100644 index 0000000..476c272 --- /dev/null +++ b/Tests/JSONTests/Release1_1/PayloadTests.swift @@ -0,0 +1,153 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import CryptoKit +import Testing +@testable import JSON + +private struct PayloadFixtures: Decodable { + struct Fixture: Decodable { + struct Envelope: Codable { let format: String; let version: Int; let utf8Base64: String } + let id: String; let inner_utf8_sha256: String; let inner_json: String; let envelope: Envelope + } + let cases: [Fixture] +} +private func payloadFixtures() throws -> PayloadFixtures { + var root = URL(fileURLWithPath: #filePath) + for _ in 0..<4 { root.deleteLastPathComponent() } + return try JSONDecoder().decode(PayloadFixtures.self, from: Data(contentsOf: root.appendingPathComponent("Tools/Release1_1Probes/Fixtures/version1.json"))) +} +private func preserved(_ value: JSON) throws -> Data { try JSONWriter(options: .init(numberStyle: .preserved)).write(value) } +private struct PayloadPair: Codable { let a: JSONCodablePayload; let b: JSONCodablePayload } + +@Test func payloadVersionOneGoldenFixturesPreserveScalarsOrderAndLexemes() throws { + let fixtures = try payloadFixtures().cases + #expect(fixtures.count == 8) + for fixture in fixtures { + let bytes = Data(fixture.inner_json.utf8) + #expect(SHA256.hash(data: bytes).map { String(format: "%02x", $0) }.joined() == fixture.inner_utf8_sha256) + let root = try JSONParser().parse(bytes) + let payload = try JSONCodablePayload(preserving: root) + let encoded = try JSONEncoder().encode(payload) + let fields = try JSONDecoder().decode(PayloadFixtures.Fixture.Envelope.self, from: encoded) + #expect(fields.format == "swift-json-value" && fields.version == 1) + #expect(fields.utf8Base64 == fixture.envelope.utf8Base64) + let decoded = try JSONDecoder().decode(JSONCodablePayload.self, from: JSONEncoder().encode(fixture.envelope)) + let opened = try decoded.value() + #expect(try preserved(opened) == bytes) + #expect(try preserved(JSONDecoder().decode(JSONCodablePayload.self, from: encoded).value()) == bytes) + } + for text in ["[]", "{}"] { + let payload = try JSONCodablePayload(preserving: JSONParser().parse(text)) + #expect(try preserved(JSONDecoder().decode(JSONCodablePayload.self, from: JSONEncoder().encode(payload)).value()) == Data(text.utf8)) + } +} +@Test func payloadForeignPropertyListAndNestedNativeCodingRemainExact() throws { + let root = try JSONParser().parse(#"{"b":-0.00e+2,"é":9007199254740993,"é":0.1,"":null}"#) + let payload = try JSONCodablePayload(preserving: root) + let pair = PayloadPair(a: payload, b: payload) + let plist = try PropertyListEncoder().encode(pair) + let reread = try PropertyListDecoder().decode(PayloadPair.self, from: plist) + #expect(try preserved(reread.a.value()) == preserved(root)) + #expect(try preserved(reread.b.value()) == preserved(root)) + let native = try JSONValueEncoder().encode(pair) + let decoded = try native.decode(PayloadPair.self) + #expect(try preserved(decoded.a.value()) == preserved(root)) + #expect(try preserved(decoded.b.value()) == preserved(root)) +} +@Test func payloadWrongFieldsAndUnsupportedVersionsUseCodableErrors() throws { + for text in [#"{}"#, #"{"format":null,"version":1,"utf8Base64":"bnVsbA=="}"#, + #"{"format":3,"version":1,"utf8Base64":"bnVsbA=="}"#, + #"{"format":"swift-json-value","version":"1","utf8Base64":"bnVsbA=="}"#, + #"{"format":"other","version":1,"utf8Base64":"bnVsbA=="}"#, + #"{"format":"swift-json-value","version":2,"utf8Base64":"bnVsbA=="}"#] { + #expect(throws: DecodingError.self) { try JSONDecoder().decode(JSONCodablePayload.self, from: Data(text.utf8)) } + } + let extra = #"{"format":"swift-json-value","version":1,"utf8Base64":"bnVsbA==","metadata":true}"# + #expect(try JSONDecoder().decode(JSONCodablePayload.self, from: Data(extra.utf8)).value().isNull) +} +@Test func payloadRejectsMalformedBase64AndCompleteInnerGrammar() throws { + var texts = ["", "A", "bnVsbA=", "bnVsbA===", "bnVs bA==", "bnVsbB==", "AB==", "AAB=", "____", "AA==AA=="] + let invalidBytes: [[UInt8]] = [[0xFF], Array("null true".utf8), Array("01".utf8), Array(#"{"é":0,"\u00e9":1}"#.utf8), Array(#""\uD800""#.utf8), Array("[1,]".utf8)] + texts += invalidBytes.map { Data($0).base64EncodedString() } // Fixture creation only. + for text in texts { + let fields = PayloadFixtures.Fixture.Envelope(format: "swift-json-value", version: 1, utf8Base64: text) + #expect(throws: DecodingError.self) { try JSONDecoder().decode(JSONCodablePayload.self, from: JSONEncoder().encode(fields)) } + } + // The shared native Data codec also requires canonical unused padding bits. + for text in ["AB==", "AAB="] { #expect(throws: DecodingError.self) { try JSONValueDecoder().decode(Data.self, from: .string(text)) } } + let spaced = PayloadFixtures.Fixture.Envelope(format: "swift-json-value", version: 1, utf8Base64: Data(" null \n".utf8).base64EncodedString()) + #expect(try JSONDecoder().decode(JSONCodablePayload.self, from: JSONEncoder().encode(spaced)).value().isNull) +} +@Test func payloadInclusiveOwnedBudgetsAndEnvelopeExpansionAreChecked() throws { + let root = try JSONParser().parse("0.1") + let made = try JSONCodablePayload.preservingWithReport(root) + let work = made.statistics[.workUnits] + var limits = JSONLimits.balanced; limits[.workUnits] = work + #expect(try JSONCodablePayload.preservingWithReport(root, limits: limits).statistics[.workUnits] == work) + limits[.workUnits] = work - 1 + #expect(throws: JSONFailure.self) { try JSONCodablePayload(preserving: root, limits: limits) } + limits = .balanced; limits[.stringBytes] = 3 // Inner 0.1 fits; MC4x does not. + #expect(throws: JSONFailure.self) { try JSONCodablePayload(preserving: root, limits: limits) } + limits = .balanced; limits[.outputBytes] = 4 // Inner fits; fixed envelope does not. + #expect(throws: JSONFailure.self) { try JSONCodablePayload(preserving: root, limits: limits) } + let opened = try made.result.valueWithReport() + limits = .balanced; limits[.retainedBytes] = opened.statistics.peakLogicalRetainedBytes + #expect(try preserved(made.result.value(limits: limits)) == preserved(root)) + limits[.retainedBytes] -= 1 + #expect(throws: JSONFailure.self) { try made.result.value(limits: limits) } + #expect(try made.result.valueWithReport(limits: .offline).statistics[.workUnits] == opened.statistics[.workUnits]) +} +@Test func payloadNativeCompositionUsesOneCumulativeMeter() throws { + let payload = try JSONCodablePayload(preserving: JSONParser().parse("[0.1,9007199254740993,null]")) + let single = try JSONValueEncoder().encodeWithReport(payload) + let pair = PayloadPair(a: payload, b: payload) + let both = try JSONValueEncoder().encodeWithReport(pair) + #expect(both.statistics[.workUnits] > single.statistics[.workUnits]) + var limits = JSONLimits.balanced; limits[.workUnits] = single.statistics[.workUnits] + #expect(throws: JSONFailure.self) { try JSONValueEncoder(options: .init(limits: limits)).encode(pair) } + let opened = try JSONValueDecoder().decodeWithReport(JSONCodablePayload.self, from: single.result) + limits = .balanced; limits[.workUnits] = opened.statistics[.workUnits] - 1 + #expect(throws: JSONFailure.self) { try JSONValueDecoder(options: .init(limits: limits)).decode(JSONCodablePayload.self, from: single.result) } + #expect(opened.statistics[.nodes] > 3) +} +private struct CatchingPayload: Decodable { + init(from decoder: any Decoder) throws { + do { _ = try JSONCodablePayload(from: decoder) } catch { /* Deliberate caller misuse. */ } + } +} +private struct InterruptingPayload: Encodable { + let payload: JSONCodablePayload + func encode(to encoder: any Encoder) throws { + let native = try #require(encoder as? JSONNativeEncoder) + let threshold = native.state.meter.used(.workUnits) + 100 + native.state.meter.checkpointObserver = { work in + if work >= threshold { withUnsafeCurrentTask { $0?.cancel() } } + } + try payload.encode(to: encoder) + } +} +@Test func payloadCaughtOperationalFailureStaysTerminalAndCorruptionDoesNot() throws { + let payload = try JSONCodablePayload(preserving: JSONParser().parse("[0.1,9007199254740993,null]")) + let envelope = try JSONValueEncoder().encode(payload) + let report = try JSONValueDecoder().decodeWithReport(JSONCodablePayload.self, from: envelope) + var limits = JSONLimits.balanced; limits[.workUnits] = report.statistics[.workUnits] - 1 + #expect(throws: JSONFailure.self) { try JSONValueDecoder(options: .init(limits: limits)).decode(CatchingPayload.self, from: envelope) } + let corrupt = try JSONParser().parse(#"{"format":"swift-json-value","version":2,"utf8Base64":"bnVsbA=="}"#) + _ = try corrupt.decode(CatchingPayload.self) // Corruption can be handled explicitly. + var meter = try JSONWorkMeter(limits: limits, domain: .coding) + try meter.work(limits[.workUnits]) + #expect(throws: JSONFailure.self) { try meter.work() } + let terminal = meter.statistics() + #expect(throws: JSONFailure.self) { try meter.checkpoint() } + #expect(meter.statistics()[.workUnits] == terminal[.workUnits]) +} +@Test func payloadCancellationDoesNotReturnAValidatedResult() async throws { + let payload = try JSONCodablePayload(preserving: .string(String(repeating: "é", count: 1024))) + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try payload.valueWithReport() + } + await #expect(throws: CancellationError.self) { try await task.value } + let inOwnedLoop = Task { try JSONValueEncoder().encode(InterruptingPayload(payload: payload)) } + await #expect(throws: CancellationError.self) { try await inOwnedLoop.value } +} From e06639bfd812634f1891bb159c6838c0175695e1 Mon Sep 17 00:00:00 2001 From: Thorsten Fuchs <76904008+ThorFuchs@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:53:35 +0200 Subject: [PATCH 5/5] Qualify public Apple consumers and disk persistence across process teardown --- CHANGELOG.md | 14 +++ .../Architecture/Release1.1/Decisions.md | 19 ++++ .../Architecture/Release1.1/Mobile.md | 8 ++ Documentation/Qualification/README.md | 8 ++ .../Sources/Consumer/READMEExamples.swift | 16 ++++ README.md | 42 ++++++++ Scripts/ci.py | 6 +- Scripts/qualify_mobile_runtime.py | 84 ++++++++++++++++ Tools/MobileQualification/Package.swift | 14 +++ Tools/MobileQualification/README.md | 29 ++++++ .../Sources/ProbeCases/DiskPersistence.swift | 93 ++++++++++++++++++ .../Sources/ProbeCases/Fixtures/version1.json | 86 +++++++++++++++++ .../Sources/ProbeCases/PublicBehavior.swift | 95 +++++++++++++++++++ .../Sources/Runtime/Runtime.swift | 22 +++++ .../Tests/ProbeTests/RuntimeTests.swift | 6 ++ 15 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 Scripts/qualify_mobile_runtime.py create mode 100644 Tools/MobileQualification/Package.swift create mode 100644 Tools/MobileQualification/README.md create mode 100644 Tools/MobileQualification/Sources/ProbeCases/DiskPersistence.swift create mode 100644 Tools/MobileQualification/Sources/ProbeCases/Fixtures/version1.json create mode 100644 Tools/MobileQualification/Sources/ProbeCases/PublicBehavior.swift create mode 100644 Tools/MobileQualification/Sources/Runtime/Runtime.swift create mode 100644 Tools/MobileQualification/Tests/ProbeTests/RuntimeTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a31350..24794ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 1.1.0 — unreleased candidate + +- Scoped typed reads with cumulative reports, native model projection and guarded + cursor invalidation; explicit numeric fidelity and absent/null presence. +- Native JSON value-coding conveniences and immutable exact Version 1 payload. +- Metered adaptive exact-key metadata and incremental duplicate recognition; + ordered members and exact collision confirmation remain authoritative. +- macOS/iOS 27 deployment minima and corrected schema bundle layout; public + SDK/simulator and disk-backed SwiftData consumers. Full physical-device + qualification remains a release gate. No tag/publication follows this change. +- Correct native ISO-8601 calendar fields and strict Base64 unused padding bits. + See API migration guidance; no limits/profile weights or dependencies raised. + + ## 1.0.0 — 2026-10-11 First stable release. diff --git a/Documentation/Architecture/Release1.1/Decisions.md b/Documentation/Architecture/Release1.1/Decisions.md index 4106480..7356b70 100644 --- a/Documentation/Architecture/Release1.1/Decisions.md +++ b/Documentation/Architecture/Release1.1/Decisions.md @@ -1,3 +1,22 @@ +# Current 1.1 decisions + +The sections below preserve the initial R0/first-filter stage history. Current +successors are [Index](Index.md), [IndexMeasurements](IndexMeasurements.md), +[Reading](Reading.md), [Persistence](Persistence.md) and [Mobile](Mobile.md). +Production selects the controlled adaptive hash index with exact confirmation, +32-member threshold and first-eight scalar lookup path. Every published object +owns valid immutable metadata; construction/native keyed storage maintain it. +The guarded reader fallback, one cumulative meter and fixed Version 1 payload +are implemented. Resource packaging is corrected under the default build engine. + +SIMD and independent-work parallel batches remain deferred conditional experiments. +The scalar path and checked Sendable immutable sharing remain. No measured mobile +crossover/energy evidence supports shipping a new accelerator, and the selected +wide-object change supplies the demonstrated workload benefit. This is a scope +decision, not completed SIMD/parallel qualification. E11/E12 remain deferred. +Physical iPad evidence blocks full R5/R8 readiness; simulator availability is not +physical qualification. The three protected Native check names remain unchanged. + # R0 architecture decisions ## Reader ownership and declarations diff --git a/Documentation/Architecture/Release1.1/Mobile.md b/Documentation/Architecture/Release1.1/Mobile.md index 5d669b3..f692b5b 100644 --- a/Documentation/Architecture/Release1.1/Mobile.md +++ b/Documentation/Architecture/Release1.1/Mobile.md @@ -19,3 +19,11 @@ iPhoneOS and iPhoneSimulator. Required subsequent evidence includes iPhone/iPad simulator behavior/cancellation, physical-device smoke and measurements, and real disk-backed SwiftData teardown/reopen using the exact payload. Keep every not-run row explicit; this document does not certify those later runs. + +The public runtime harness now executes exact numeric/scalar conversion, offline +plans/native regex, integrity-checked packaged schemas, Patch/Merge Patch, payload +fixtures, stream completion/terminal pulls, awaited sinks and cancellation. Mac +Swift Testing and isolated iPhone/iPad iOS 27 simulators execute three-process +disk save/reopen/update checks. The physical iPhone is connected with iOS 27.2 +and developer mode enabled; signing/runtime/performance are separately qualified. +The physical iPad is unavailable and remains a mandatory R5 release blocker. diff --git a/Documentation/Qualification/README.md b/Documentation/Qualification/README.md index 68f9f4a..3ef6d3c 100644 --- a/Documentation/Qualification/README.md +++ b/Documentation/Qualification/README.md @@ -12,6 +12,14 @@ and seven additional nonparameterized cases. The first production lookup increme the independent runner adds nine budget/report vectors. There are three separate JSON/JSONSchema consumer tests. All discoveries must be nonzero. +The current integrated 1.1 candidate checks exactly 156 root functions: +JSON 86, JSONStreaming 11, JSONSchema 38, JSONRegexRuntime 12 and JSONPatch 9. +All 173 parameterized invocations remain. The earlier counts above describe their +historical stages, not this source. Three additional public/mobile Swift Testing +functions exercise eight pinned payload fixtures and three independently exited +SwiftData disk phases. The seven-function R0 subset, all 49 cap probes, rooted +20-case consumer and independent policies retain their exact checks. + | Independent input | Observed policy | | --- | --- | | JSONTestSuite, pinned in FixtureRegistry.json | 318 discovered: 101 accepted, 186 rejected, 31 strict/finite-policy rejections | diff --git a/Examples/Consumer/Sources/Consumer/READMEExamples.swift b/Examples/Consumer/Sources/Consumer/READMEExamples.swift index 3e18e28..9c78854 100644 --- a/Examples/Consumer/Sources/Consumer/READMEExamples.swift +++ b/Examples/Consumer/Sources/Consumer/READMEExamples.swift @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 // Compiled README examples; checked by the documentation lane. import JSON +import Foundation import JSONSchema import JSONPatch import JSONStreaming @@ -55,6 +56,21 @@ func runREADMEExamples() throws { let decoded = try JSONParser().decode(Reading.self, from: bytes) precondition(decoded == reading) + let scoped = try value.readWithReport { root in + let exactCount = try root["count"].requiredInteger(as: UInt64.self) + let optionalNote = try root.stringPresence(of: "note") + return (exactCount, optionalNote) + } + precondition(scoped.result.0 == 9_007_199_254_740_993) + precondition(scoped.result.1 == .null) + precondition(scoped.statistics[.workUnits] > 0) + + let payload = try JSONCodablePayload(preserving: value) + let transported = try PropertyListEncoder().encode(payload) + let restored = try PropertyListDecoder().decode(JSONCodablePayload.self, from: transported) + let restoredValue = try restored.value() + precondition(restoredValue == value) + let schema = try JSONSchemaDocument(parser.parse( #"{"type":"object","properties":{"count":{"type":"integer"}},"required":["count"]}"# )) diff --git a/README.md b/README.md index 4c2f9db..7e5330e 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,48 @@ by default; explicit ISO-8601 uses bounded UTC text with fractional seconds and four-digit years. Foreign Codable encoders/decoders determine their own numeric and key fidelity. Caller callbacks remain caller-owned work. +## Development toward 1.1 + +The 1.1 feature branches add scoped reads, native value-coding conveniences, +exact Version 1 persistence and a measured private wide-object index. The released +1.0 package remains available through the installation declaration below. +Physical iPad qualification is pending; SDK/simulator evidence is separate from +release support. See the [release plan](Documentation/Architecture/Release1.1/Plan.md). + +```swift +let scoped = try value.readWithReport { root in + let exactCount = try root["count"].requiredInteger(as: UInt64.self) + let optionalNote = try root.stringPresence(of: "note") + return (exactCount, optionalNote) +} +precondition(scoped.result.0 == 9_007_199_254_740_993) +precondition(scoped.result.1 == .null) +precondition(scoped.statistics[.workUnits] > 0) +``` + +Throwing String/JSONKey/Int brackets preserve literal scalar keys and typed array +indices. Required access never coerces or defaults. Presence distinguishes absent, +null and a non-null value; native model projection shares the scope's cumulative +meter. Double/Decimal getters require an explicit exact or rounded policy. +Escaped cursors invalidate at scope exit and retain no selected root/source. + +```swift +import Foundation + +let payload = try JSONCodablePayload(preserving: value) +let transported = try PropertyListEncoder().encode(payload) +let restored = try PropertyListDecoder().decode(JSONCodablePayload.self, from: transported) +let restoredValue = try restored.value() +precondition(restoredValue == value) +``` + +The explicit wrapper transports a fixed ASCII envelope with strict Base64 of +complete JSON, preserving exact decimals, scalar sequences, order and admitted +number lexemes. It retains validated bytes only. Automatic coding uses finite +balanced limits; explicit limits can tighten them. Foreign coder work remains +caller work. For SwiftData, use `@Attribute(.codable)` in your application target; +see the [executed disk example](Tools/MobileQualification/README.md). + ## Compile and validate schemas offline ```swift diff --git a/Scripts/ci.py b/Scripts/ci.py index c32f7ed..d0925f0 100644 --- a/Scripts/ci.py +++ b/Scripts/ci.py @@ -25,6 +25,8 @@ def commands(lane, output): ['python3', '-B', 'Scripts/qualify_release_1_1.py', '--mode', 'ownership', '--output', str(output / 'ownership.json')], ['python3', '-B', 'Scripts/qualify_release_1_1.py', '--mode', 'reader', '--output', str(output / 'reader.json')], ['swift', 'run', '-c', 'release', '--package-path', 'Examples/Consumer', 'Consumer'], + ['swift', 'test', '--package-path', 'Tools/MobileQualification'], + ['swift', 'run', '-c', 'release', '--package-path', 'Tools/MobileQualification', 'MobileQualification'], ['swift', 'test', '--package-path', 'Tools/MCPReadiness'], ['swift', 'run', '-c', 'release', '--package-path', 'Tools/MCPReadiness', 'MCPReadiness', str(output / 'consumer.json')], ['swift', 'test', '--package-path', 'Tools/Qualification'], @@ -69,7 +71,7 @@ def check_result(command, code, log): counts = [int(n) for n in re.findall(r'Test run with (\d+) tests? in[^\n]*passed', log)] passed = code == 0 if command[:2] == ['swift', 'test']: - expected = [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command else [1] if 'Tools/Qualification' in command else [1, 1, 2] if 'thread' in command else [9, 11, 12, 38, 78] + expected = [7] if R0_TESTS in command else [3] if 'Tools/MCPReadiness' in command or 'Tools/MobileQualification' in command else [1] if 'Tools/Qualification' in command else [1, 1, 2] if 'thread' in command else [9, 11, 12, 38, 86] passed = passed and sorted(counts) == expected if 'Tools/Qualification' in command: probes = re.findall(r'^CAP_PROBE (\w+) (\S+)$', log, re.MULTILINE) @@ -81,6 +83,8 @@ def check_result(command, code, log): passed = sorted(p['name'] for p in d['products']) == ['JSON', 'JSONPatch', 'JSONSchema', 'JSONStreaming'] and not d['dependencies'] and d['swiftLanguageVersions'] == ['6'] and d['toolsVersion']['_version'] == '6.4.0' and d['platforms'] == [{'options': [], 'platformName': 'macos', 'version': '27.0'}, {'options': [], 'platformName': 'ios', 'version': '27.0'}] if command == ['swift', 'package', 'show-dependencies', '--format', 'json'] and passed: passed = json.loads(log)['dependencies'] == [] + if command[:2] == ['swift', 'run'] and 'Tools/MobileQualification' in command: + passed = passed and all(line in log.splitlines() for line in ['PUBLIC_BEHAVIOR passed fixtures=8', 'STREAM_CANCELLATION passed', 'DISK_PERSISTENCE passed containers=3 rows=8', 'MOBILE_QUALIFICATION passed cases=3']) return passed, counts def main(): diff --git a/Scripts/qualify_mobile_runtime.py b/Scripts/qualify_mobile_runtime.py new file mode 100644 index 0000000..c83d05d --- /dev/null +++ b/Scripts/qualify_mobile_runtime.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Build both mobile SDKs and run isolated iPhone/iPad simulator consumers.""" +import argparse, datetime, hashlib, json, pathlib, subprocess, sys +from archive_candidate import files +from preflight import collect +ROOT = pathlib.Path(__file__).resolve().parents[1] +EXPECTED = ['PUBLIC_BEHAVIOR passed fixtures=8', 'STREAM_CANCELLATION passed', + 'DISK_PERSISTENCE passed containers=3 rows=8', 'MOBILE_QUALIFICATION passed cases=3'] + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', default='.build/ci/mobile-runtime/receipt.json') + args = parser.parse_args() + output = ROOT / args.output + output.parent.mkdir(parents=True, exist_ok=True) + source = [{'path': p.relative_to(ROOT).as_posix(), 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()} for p in files()] + receipt = {'schema_version': 1, 'status': 'running', 'environment': collect(), + 'git_head': subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(), + 'source_inputs': source, 'commands': [], 'simulators': [], 'physical_devices': 'separate physical receipts required; iPad unavailable, confirmed by maintainer', + 'started_utc': datetime.datetime.now(datetime.timezone.utc).isoformat()} + def save(): output.write_text(json.dumps(receipt, indent=2) + '\n') + def run(name, command): + print('Running ' + name, flush=True) + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=900) + path = output.parent / (name + '.log') + path.write_text((result.stdout + result.stderr).replace(str(ROOT), '.')) + receipt['commands'].append({'name': name, 'command': [x.replace(str(ROOT), '.') for x in command], + 'exit_code': result.returncode, 'log': path.relative_to(ROOT).as_posix(), + 'log_sha256': hashlib.sha256(path.read_bytes()).hexdigest()}) + save() + if result.returncode: raise RuntimeError(name + ' failed') + return result.stdout + save() + try: + if receipt['environment']['status'] != 'passed': raise FileNotFoundError('Supported native host required') + for sdk, triple, scratch in [('iphoneos', 'arm64-apple-ios27.0', '.build/mobile-runtime-device'), + ('iphonesimulator', 'arm64-apple-ios27.0-simulator', '.build/mobile-runtime-sim')]: + sdkpath = run(sdk + '-sdk', ['xcrun', '--sdk', sdk, '--show-sdk-path']).strip() + run(sdk + '-build', ['swift', 'build', '-c', 'release', '--package-path', 'Tools/MobileQualification', + '--scratch-path', scratch, '--triple', triple, '--sdk', sdkpath]) + # Resolve the output actually produced by the default Swift Build engine. + candidates = [p for p in (ROOT / '.build/mobile-runtime-sim/out/Products').glob('Release*/MobileQualification') if p.is_file()] + if len(candidates) != 1: raise RuntimeError('Expected one simulator executable') + binary = candidates[0] + receipt['binary_sha256'] = hashlib.sha256(binary.read_bytes()).hexdigest() + inventory = json.loads(run('available-simulators', ['xcrun', 'simctl', 'list', 'devices', 'available', '--json']))['devices'] + runtime = 'com.apple.CoreSimulator.SimRuntime.iOS-27-0' + devices = inventory.get(runtime, []) + for family in ['iPhone', 'iPad']: + model = next((d for d in devices if d['name'].startswith(family) and d['isAvailable']), None) + if model is None: raise FileNotFoundError('No supported ' + family + ' simulator model/runtime') + device = run(family + '-create', ['xcrun', 'simctl', 'create', 'Swift-JSON-1.1-' + family, + model['deviceTypeIdentifier'], runtime]).strip() + try: + run(family + '-boot', ['xcrun', 'simctl', 'boot', device]) + run(family + '-bootstatus', ['xcrun', 'simctl', 'bootstatus', device, '-b']) + result = run(family + '-consumer', ['xcrun', 'simctl', 'spawn', device, str(binary)]) + if not all(line in result.splitlines() for line in [EXPECTED[0], EXPECTED[1], 'MOBILE_QUALIFICATION passed cases=2']): raise RuntimeError('Missing exact runtime outcomes') + disk = output.parent / ('disk-' + family) + disk.mkdir(parents=True, exist_ok=True) + url = disk / 'snapshots.store' + for phase in ['save', 'reopen-update', 'verify-update']: + result = run(family + '-disk-' + phase, ['xcrun', 'simctl', 'spawn', device, str(binary), '--disk-phase', phase, str(url)]) + if 'DISK_PHASE passed phase=' + phase + ' rows=8' not in result.splitlines(): + raise RuntimeError('Missing disk phase outcome') + if not url.is_file(): raise RuntimeError('No actual disk store') + receipt['simulators'].append({'family': family, 'model': model['name'], 'runtime': runtime, + 'status': 'passed', 'executed_cases': 3, 'fixtures': 8, 'disk_containers': 3}) + save() + finally: + run(family + '-delete', ['xcrun', 'simctl', 'delete', device]) + receipt['status'] = 'passed' + except FileNotFoundError as error: + receipt['status'] = 'blocked'; receipt['error'] = str(error) + except (OSError, RuntimeError, ValueError, subprocess.TimeoutExpired) as error: + receipt['status'] = 'failed'; receipt['error'] = str(error) + receipt['source_inputs_unchanged'] = source == [{'path': p.relative_to(ROOT).as_posix(), 'sha256': hashlib.sha256(p.read_bytes()).hexdigest()} for p in files()] + if not receipt['source_inputs_unchanged']: receipt['status'] = 'failed' + receipt['finished_utc'] = datetime.datetime.now(datetime.timezone.utc).isoformat() + save(); print(json.dumps({k:v for k,v in receipt.items() if k not in ['source_inputs', 'environment']}, indent=2)) + return 0 if receipt['status'] == 'passed' else 2 if receipt['status'] == 'blocked' else 1 + +if __name__ == '__main__': sys.exit(main()) diff --git a/Tools/MobileQualification/Package.swift b/Tools/MobileQualification/Package.swift new file mode 100644 index 0000000..126610d --- /dev/null +++ b/Tools/MobileQualification/Package.swift @@ -0,0 +1,14 @@ +// swift-tools-version: 6.4 +// SPDX-License-Identifier: Apache-2.0 +import PackageDescription +let package = Package(name: "MobileQualification", platforms: [.macOS("27.0"), .iOS("27.0")], + products: [.executable(name: "MobileQualification", targets: ["Runtime"])], + dependencies: [.package(name: "Swift-JSON", path: "../..")], + targets: [ + .target(name: "ProbeCases", dependencies: [ + .product(name: "JSON", package: "Swift-JSON"), .product(name: "JSONSchema", package: "Swift-JSON"), + .product(name: "JSONPatch", package: "Swift-JSON"), .product(name: "JSONStreaming", package: "Swift-JSON")], + resources: [.copy("Fixtures/version1.json")]), + .executableTarget(name: "Runtime", dependencies: ["ProbeCases"]), + .testTarget(name: "ProbeTests", dependencies: ["ProbeCases"]) + ], swiftLanguageModes: [.v6]) diff --git a/Tools/MobileQualification/README.md b/Tools/MobileQualification/README.md new file mode 100644 index 0000000..f7493df --- /dev/null +++ b/Tools/MobileQualification/README.md @@ -0,0 +1,29 @@ +# Public Apple runtime and SwiftData example + +This separate package imports all four public products and SwiftData. It adds no +SwiftData dependency to the root package. Native host requirements remain Swift +6.4/language 6/macOS and SDK 27; the mobile minima are iOS/iPadOS 27. + +```sh +swift test --package-path Tools/MobileQualification +swift run -c release --package-path Tools/MobileQualification MobileQualification +python3 Scripts/qualify_mobile_runtime.py --output .build/ci/mobile-runtime/receipt.json +``` + +Three Swift Testing functions check exact public APIs/fixtures, stream/sink +completion/cancellation and disk persistence. Eight frozen Version 1 fixtures +preserve scalar bytes, exact decimals beyond UInt64/Decimal, order and lexemes. +The model uses explicit nonoptional/optional `@Attribute(.codable)` payloads and +independent applicationSchemaVersion. Query/sort ordinary name fields; embedded +JSON predicates and cloud synchronization are not claimed. + +Each disk phase runs in a separate exited process: save, reopen/check/update, +reopen/check updated data. All use one disk URL. This ensures prior models, +contexts, containers and SDK-retained references are gone. A first simulator +release probe found SDK container retention beyond lexical scope; its weak-reference +failure remains evidence. Mac test-bundle layouts locate the actual built runtime +within five ancestor checks. The simulator controller creates/deletes only its own +isolated iPhone/iPad devices and records exact outcomes and source hashes. + +SDK compilation, simulator runtime and physical-device performance are distinct +claims. The physical iPad is unavailable; its agreed gate remains blocked. diff --git a/Tools/MobileQualification/Sources/ProbeCases/DiskPersistence.swift b/Tools/MobileQualification/Sources/ProbeCases/DiskPersistence.swift new file mode 100644 index 0000000..866de89 --- /dev/null +++ b/Tools/MobileQualification/Sources/ProbeCases/DiskPersistence.swift @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import SwiftData +import JSON + +@Model final class ConfigurationSnapshot { + var name: String + var applicationSchemaVersion: Int + @Attribute(.codable) var payload: JSONCodablePayload + @Attribute(.codable) var optionalPayload: JSONCodablePayload? + init(name: String, payload: JSONCodablePayload, optional: JSONCodablePayload?) { + self.name = name; applicationSchemaVersion = 1 + self.payload = payload; optionalPayload = optional + } +} +@MainActor private func container(_ url: URL) throws -> ModelContainer { + try ModelContainer(for: ConfigurationSnapshot.self, configurations: ModelConfiguration(url: url)) +} +@MainActor private func save(_ url: URL, fixtures: FrozenFixtures) throws { + try autoreleasepool { + let store = try container(url) + let context = ModelContext(store); context.autosaveEnabled = false + for item in fixtures.cases { + let payload = try JSONCodablePayload(preserving: JSONParser().parse(item.inner_json)) + context.insert(ConfigurationSnapshot(name: item.id, payload: payload, + optional: item.id == "null" ? try JSONCodablePayload(preserving: .null) : nil)) + } + try context.save() + } +} +@MainActor private func reopenAndUpdate(_ url: URL, fixtures: FrozenFixtures) throws { + try autoreleasepool { + let store = try container(url) + let context = ModelContext(store); context.autosaveEnabled = false + let rows = try context.fetch(FetchDescriptor(sortBy: [SortDescriptor(\.name)])) + try verify(rows.count == 8 && rows.map(\.name) == fixtures.cases.map(\.id).sorted(), "ordinary field sorting") + for row in rows { + let fixture = fixtures.cases.first { $0.id == row.name }! + try verify(exactBytes(row.payload.value()) == Data(fixture.inner_json.utf8), "disk fidelity including scalar/order/lexeme") + try verify(row.applicationSchemaVersion == 1, "application schema version") + if row.name == "null" { try verify(row.optionalPayload?.value().isNull == true, "present null attribute") } + else { try verify(row.optionalPayload == nil, "absent optional attribute") } + } + let selection = FetchDescriptor(predicate: #Predicate { $0.name == "decimal" }) + let decimal = try context.fetch(selection) + try verify(decimal.count == 1, "ordinary field predicate") + decimal[0].payload = try JSONCodablePayload(preserving: JSONParser().parse("0.1000e+1")) + decimal[0].applicationSchemaVersion = 2 + decimal[0].optionalPayload = try JSONCodablePayload(preserving: .null) + try context.save() + } +} +// Each phase is executed by a distinct process. Process exit closes the store and +// removes all model/context/container state, including SDK-retained references. +@MainActor public func diskPhase(_ phase: String, url: URL) throws { + let fixtures = try FrozenFixtures.load() + if phase == "save" { try save(url, fixtures: fixtures); return } + try verify(FileManager.default.fileExists(atPath: url.path), "actual disk store exists") + if phase == "reopen-update" { try reopenAndUpdate(url, fixtures: fixtures); return } + try verify(phase == "verify-update", "known disk phase") + try autoreleasepool { + let store = try container(url), context = ModelContext(store) + let rows = try context.fetch(FetchDescriptor(predicate: #Predicate { $0.name == "decimal" })) + try verify(rows.count == 1 && rows[0].applicationSchemaVersion == 2, "updated ordinary field persisted") + try verify(exactBytes(rows[0].payload.value()) == Data("0.1000e+1".utf8), "updated lexeme persisted") + try verify(rows[0].optionalPayload?.value().isNull == true, "updated present null persisted") + } +} +#if os(macOS) +public func diskPersistence() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("swift-json-disk-" + UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("snapshots.store") + var products = Bundle.module.bundleURL.deletingLastPathComponent() + for _ in 0..<5 { + if FileManager.default.isExecutableFile(atPath: products.appendingPathComponent("MobileQualification").path) { break } + products.deleteLastPathComponent() + } + let executable = products.appendingPathComponent("MobileQualification") + for phase in ["save", "reopen-update", "verify-update"] { + let process = Process(), pipe = Pipe() + process.executableURL = executable + process.arguments = ["--disk-phase", phase, url.path] + process.standardOutput = pipe; process.standardError = pipe + try process.run() + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + try verify(process.terminationReason == .exit && process.terminationStatus == 0, "disk child process exit " + phase) + try verify(String(decoding: output, as: UTF8.self).split(separator: "\n").contains(Substring("DISK_PHASE passed phase=" + phase + " rows=8")), "disk child process outcome") + } +} +#endif diff --git a/Tools/MobileQualification/Sources/ProbeCases/Fixtures/version1.json b/Tools/MobileQualification/Sources/ProbeCases/Fixtures/version1.json new file mode 100644 index 0000000..ca0cd0c --- /dev/null +++ b/Tools/MobileQualification/Sources/ProbeCases/Fixtures/version1.json @@ -0,0 +1,86 @@ +{ + "format_version": 1, + "status": "logical-envelope-fixtures; exact persistence codec qualification not yet run", + "cases": [ + { + "id": "null", + "inner_utf8_sha256": "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b", + "inner_json": "null", + "envelope": { + "format": "swift-json-value", + "version": 1, + "utf8Base64": "bnVsbA==" + } + }, + { + "id": "boolean", + "inner_utf8_sha256": "b5bea41b6c623f7c09f1bf24dcae58ebab3c0cdd90ad966bc43a45b44867e12b", + "inner_json": "true", + "envelope": { + "format": "swift-json-value", + "version": 1, + "utf8Base64": "dHJ1ZQ==" + } + }, + { + "id": "string", + "inner_utf8_sha256": "06c3994e4804c1a17dfcc7607f72944152a706367c0c322b34fd9a8f1b1a455d", + "inner_json": "\"é é 🚀\"", + "envelope": { + "format": "swift-json-value", + "version": 1, + "utf8Base64": "IsOpIGXMgSDwn5qAIg==" + } + }, + { + "id": "decimal", + "inner_utf8_sha256": "14be4b45f18e0d8c67b4f719b5144eee88497e413709d11d85b096d8e2346310", + "inner_json": "0.1", + "envelope": { + "format": "swift-json-value", + "version": 1, + "utf8Base64": "MC4x" + } + }, + { + "id": "lexeme", + "inner_utf8_sha256": "59bbf72ab162059f732c213462a666646ff97896f9efd0b2ec53b97dda502472", + "inner_json": "-0.00e+2", + "envelope": { + "format": "swift-json-value", + "version": 1, + "utf8Base64": "LTAuMDBlKzI=" + } + }, + { + "id": "large-number", + "inner_utf8_sha256": "decc538c077786966ac863b5532c4027b8587ff40f6e3103379af62b44eae44d", + "inner_json": "123456789012345678901234567890123456789012345678901234567890", + "envelope": { + "format": "swift-json-value", + "version": 1, + "utf8Base64": "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkw" + } + }, + { + "id": "array", + "inner_utf8_sha256": "c742fdf61c92ed1d0081e93b41b69224909adffd4484ae4a023085d827ccb229", + "inner_json": "[1e0,null,\"x\"]", + "envelope": { + "format": "swift-json-value", + "version": 1, + "utf8Base64": "WzFlMCxudWxsLCJ4Il0=" + } + }, + { + "id": "ordered-scalars", + "inner_utf8_sha256": "57c900d6ddd444977ea738f05bf6c030f968423d21f1a1410384841af835b048", + "inner_json": "{\"b\":1.00,\"é\":2,\"é\":3,\"a\":-0}", + "envelope": { + "format": "swift-json-value", + "version": 1, + "utf8Base64": "eyJiIjoxLjAwLCLDqSI6MiwiZcyBIjozLCJhIjotMH0=" + } + } + ] +} diff --git a/Tools/MobileQualification/Sources/ProbeCases/PublicBehavior.swift b/Tools/MobileQualification/Sources/ProbeCases/PublicBehavior.swift new file mode 100644 index 0000000..0b8da28 --- /dev/null +++ b/Tools/MobileQualification/Sources/ProbeCases/PublicBehavior.swift @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import CryptoKit +import JSON +import JSONSchema +import JSONPatch +import JSONStreaming + +public enum ProbeFailure: Error { case assertion(String) } +public func verify(_ condition: Bool, _ name: String) throws { if !condition { throw ProbeFailure.assertion(name) } } +public struct FrozenFixtures: Decodable { + public struct Item: Decodable { + public let id: String + public let inner_json: String + public let inner_utf8_sha256: String + } + public let cases: [Item] + public static func load() throws -> Self { + guard let url = Bundle.module.url(forResource: "version1", withExtension: "json") else { throw ProbeFailure.assertion("fixture resource") } + let value = try JSONDecoder().decode(Self.self, from: Data(contentsOf: url)) + try verify(value.cases.count == 8, "eight pinned fixtures") + for item in value.cases { + let hash = SHA256.hash(data: Data(item.inner_json.utf8)).map { String(format: "%02x", $0) }.joined() + try verify(hash == item.inner_utf8_sha256, "fixture SHA-256") + } + return value + } +} +public func exactBytes(_ value: JSON) throws -> Data { try JSONWriter(options: .init(numberStyle: .preserved)).write(value) } +public func publicBehavior() throws { + let value = try JSONParser().parse(#"{"count":9007199254740993,"é":1,"é":2,"items":[0.1,null]}"#) + let report = try value.readWithReport { root in + try verify(root["count"].requiredInteger(as: UInt64.self) == 9007199254740993, "exact integer") + try verify(root["é"].requiredInteger(as: Int.self) == 1 && root["é"].requiredInteger(as: Int.self) == 2, "scalar key identity") + do { _ = try root["items"][0].requiredDouble(policy: .exact); throw ProbeFailure.assertion("exact binary refusal") } + catch let error as JSONAccessFailure { try verify(error.cause.category == .conversion, "conversion classification") } + try verify(root["items"][0].requiredDouble(policy: .rounded) == 0.1, "finite rounded binary bridge") + } + try verify(report.statistics[.workUnits] > 0 && report.statistics[.numericWorkUnits] > 0, "public cumulative counts") + let registry = try JSONSchemaRegistry.draft2020_12() + try verify(registry.documents.count == 9, "packaged pinned schemas with CryptoKit") + let schema = try JSONSchemaDocument(JSONParser().parse(#"{"type":"object","properties":{"count":{"minimum":9007199254740993},"é":{"type":"integer"}},"required":["count","é"],"propertyNames":{"pattern":"^.+$"}}"#)) + let plan = try JSONSchemaCompiler().compile(schema) + try verify(JSONSchemaValidator().validate(value, against: plan).isValid, "offline plan/native regex valid") + try verify(!JSONSchemaValidator().validate(.null, against: plan).isValid, "ordinary invalid verdict") + let patch = try JSONPatch(json: JSONParser().parse(#"[{"op":"test","path":"/count","value":9007199254740993},{"op":"replace","path":"/é","value":3}]"#)) + guard case .document(let changed) = try patch.applying(to: value) else { throw ProbeFailure.assertion("unexpected removed root") } + try verify(changed.read { try $0["é"].requiredInteger(as: Int.self) } == 3 && value.read { try $0["é"].requiredInteger(as: Int.self) } == 1, "staged Patch") + try verify(JSONMergePatch(.null).applying(to: value).isNull, "Merge Patch null") + for fixture in try FrozenFixtures.load().cases { + let payload = try JSONCodablePayload(preserving: JSONParser().parse(fixture.inner_json)) + let reopened = try PropertyListDecoder().decode(JSONCodablePayload.self, from: PropertyListEncoder().encode(payload)) + try verify(exactBytes(reopened.value()) == Data(fixture.inner_json.utf8), "opaque payload fidelity") + } +} +private struct Chunks: AsyncSequence, Sendable { + typealias Element = JSONByteChunk + let text: String + func makeAsyncIterator() -> Iterator { .init(bytes: Array(text.utf8)) } + struct Iterator: AsyncIteratorProtocol { + let bytes: [UInt8]; var offset = 0 + mutating func next() async -> JSONByteChunk? { + guard offset < bytes.count else { return nil } + defer { offset += 1 }; return .init([bytes[offset]]) + } + } +} +private actor Sink { + var bytes: [UInt8] = [] + func accept(_ chunk: JSONByteChunk) async { await Task.yield(); bytes.append(contentsOf: chunk.bytes) } +} +public func streamAndCancellation() async throws { + var elements = 0, completed = 0 + for try await event in JSONTopLevelArraySequence(Chunks(text: "[0.1,null] \n")) { + switch event { case .element(let index, _): try verify(index == UInt64(elements), "stream order"); elements += 1 + case .documentCompleted(let summary): try verify(summary.records == 2, "certified record count"); completed += 1 } + } + try verify(elements == 2 && completed == 1, "explicit completion") + var bad = JSONTopLevelArraySequence(Chunks(text: "[0]x")).makeAsyncIterator() + do { + while let event = try await bad.next() { if case .documentCompleted = event { throw ProbeFailure.assertion("premature completion") } } + throw ProbeFailure.assertion("trailing grammar accepted") + } catch let error as JSONFailure { try verify(error.category == .syntax, "operational syntax error") } + let end = try await bad.next(); try verify(end == nil, "terminal pull") + let sink = Sink(), value = try JSONParser().parse(#"{"x":[0.1,null,"é"]}"#) + let statistics = try await JSONAsyncWriter(chunkSize: 3).writeWithReport(value) { await sink.accept($0) } + let written = await sink.bytes + try verify(written == Array(JSONWriter().write(value)) && statistics[.outputBytes] == UInt64(written.count), "awaited sink") + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + return try value.read { _ in true } + } + do { _ = try await task.value; throw ProbeFailure.assertion("cancelled read succeeded") } + catch is CancellationError { } +} diff --git a/Tools/MobileQualification/Sources/Runtime/Runtime.swift b/Tools/MobileQualification/Sources/Runtime/Runtime.swift new file mode 100644 index 0000000..2f9cc80 --- /dev/null +++ b/Tools/MobileQualification/Sources/Runtime/Runtime.swift @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import ProbeCases +@main struct Runtime { + static func main() async throws { + if CommandLine.arguments.count == 4, CommandLine.arguments[1] == "--disk-phase" { + let phase = CommandLine.arguments[2], url = URL(fileURLWithPath: CommandLine.arguments[3]) + try await MainActor.run { try diskPhase(phase, url: url) } + print("DISK_PHASE passed phase=" + phase + " rows=8") + return + } + try publicBehavior(); print("PUBLIC_BEHAVIOR passed fixtures=8") + try await streamAndCancellation(); print("STREAM_CANCELLATION passed") + #if os(macOS) + try diskPersistence(); print("DISK_PERSISTENCE passed containers=3 rows=8") + print("MOBILE_QUALIFICATION passed cases=3") + #else + print("DISK_PERSISTENCE requires three separate phase processes") + print("MOBILE_QUALIFICATION passed cases=2") + #endif + } +} diff --git a/Tools/MobileQualification/Tests/ProbeTests/RuntimeTests.swift b/Tools/MobileQualification/Tests/ProbeTests/RuntimeTests.swift new file mode 100644 index 0000000..f2589ef --- /dev/null +++ b/Tools/MobileQualification/Tests/ProbeTests/RuntimeTests.swift @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +import Testing +import ProbeCases +@Test func publicProductsAndPayloadFixtures() throws { try publicBehavior() } +@Test func awaitedStreamsAndCancellation() async throws { try await streamAndCancellation() } +@Test @MainActor func swiftDataDiskTeardownReopenAndUpdate() throws { try diskPersistence() }