Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/GraphQL/Language/AST.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ public final class Token: @unchecked Sendable {
* including ignored tokens. <SOF> is always the first node and <EOF>
* the last.
*/
public let prev: Token?
public private(set) weak var prev: Token?
public internal(set) var next: Token?

init(
Expand Down
7 changes: 4 additions & 3 deletions Sources/GraphQL/Validation/ValidationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,10 @@ public final class ValidationContext: ASTValidationContext {
recursiveVariableUsages = [:]

super.init(ast: ast) { _ in }
onError = { error in
self.errors.append(error)
}
}

public override func report(error: GraphQLError) {
errors.append(error)
Comment thread
NeedleInAJayStack marked this conversation as resolved.
Outdated
}

func getSchema() -> GraphQLSchema? {
Expand Down
65 changes: 65 additions & 0 deletions Tests/GraphQLTests/GraphQLMemoryRetentionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Testing

@testable import GraphQL

// Holds a weak reference to a Token so we can observe when ARC frees it.
private final class WeakTokenBox {
weak var token: Token?
}

@Suite struct GraphQLMemoryRetentionTests {
private func makeSchema() throws -> GraphQLSchema {
try GraphQLSchema(
query: GraphQLObjectType(
name: "Query",
fields: ["hello": GraphQLField(type: GraphQLString)]
)
)
}

// Regression test for Token.prev being a strong reference.
//
// Adjacent tokens in the linked list formed a mutual retain cycle:
// SOF.next → T1 and T1.prev → SOF. When the Document went out of scope,
// neither token could be freed. Making prev `weak` breaks the cycle.
@Test func tokenChainIsReleasedAfterDocumentGoesOutOfScope() throws {
let box = WeakTokenBox()

func parseAndCapture() throws {
let document = try parse(source: "{ hello }")
// startToken is SOF (no prev). Capture the next token, which
// has a .prev back to SOF — the exact edge the fix targets.
box.token = document.loc?.startToken.next
#expect(box.token != nil)
}

try parseAndCapture()
#expect(box.token == nil)
}

// Regression test for ValidationContext.init capturing `self` in a closure.
//
// The onError closure stored on ASTValidationContext captured ValidationContext
// strongly, creating a cycle that prevented the context (and the Document it
// holds) from ever being freed. Overriding report(error:) instead eliminates
// the closure and the cycle.
//
// SOF (startToken) has no .prev, so it has no token-chain cycle. If it is
// retained after this scope it must be because ValidationContext leaked and
// is still holding a copy of the Document.
@Test func validationContextReleasesDocumentAfterValidation() throws {
let schema = try makeSchema()
let box = WeakTokenBox()

func validateAndCapture() throws {
let document = try parse(source: "{ hello }")
box.token = document.loc?.startToken // SOF — no prev, no token cycle
let errors = validate(schema: schema, ast: document)
#expect(errors.isEmpty)
#expect(box.token != nil)
}

try validateAndCapture()
#expect(box.token == nil)
}
}
Loading