Skip to content

Commit 4acb12f

Browse files
authored
Fix cross-terminal hyperlink GC and make TinyAtom thread-safe (#611)
1 parent 6918d74 commit 4acb12f

3 files changed

Lines changed: 79 additions & 23 deletions

File tree

Sources/SwiftTerm/CharData.swift

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,9 @@ public struct Attribute: Equatable, Hashable {
190190
/// it could in theory be changed to be 24 bits without much trouble
191191
public struct TinyAtom {
192192
var code: UInt16
193-
static var map: [UInt16:Any] = [:]
194-
static var lastUsed: Int = 0
195-
static var lastCollected: Int = 0
193+
private static let lock = NSLock()
194+
private static var map: [UInt16:Any] = [:]
195+
private static var lastUsed: UInt16 = 0
196196
static let empty = TinyAtom (code: 0)
197197

198198
private init(code: UInt16)
@@ -202,17 +202,30 @@ public struct TinyAtom {
202202

203203
/// Returns the TinyAtom associated with the specified url, or nil if we ran out of space
204204
public static func lookup (value: Any) -> TinyAtom? {
205-
let next = lastUsed + 1
206-
if next < UInt16.max {
207-
map [UInt16 (next)] = value
208-
lastUsed = next
209-
return TinyAtom (code: UInt16 (next))
205+
lock.lock()
206+
defer { lock.unlock() }
207+
208+
guard lastUsed < UInt16.max - 1 else {
209+
return nil
210210
}
211-
return nil
211+
lastUsed += 1
212+
let code = lastUsed
213+
214+
map [code] = value
215+
return TinyAtom (code: code)
212216
}
213217

214218
public static func release(code: UInt16) {
215-
map.removeValue(forKey: code)
219+
release(codes: [code])
220+
}
221+
222+
static func release<S: Sequence>(codes: S) where S.Element == UInt16 {
223+
lock.lock()
224+
defer { lock.unlock() }
225+
226+
for code in codes where code != 0 {
227+
map.removeValue(forKey: code)
228+
}
216229
}
217230

218231
/// Returns the target for the TinyAtom
@@ -221,6 +234,8 @@ public struct TinyAtom {
221234
if code == 0 {
222235
return nil
223236
}
237+
TinyAtom.lock.lock()
238+
defer { TinyAtom.lock.unlock() }
224239
return TinyAtom.map [code]
225240
}
226241
}

Sources/SwiftTerm/Terminal.swift

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,10 @@ open class Terminal {
706706
setup()
707707
}
708708

709+
deinit {
710+
TinyAtom.release(codes: payloadCodes)
711+
}
712+
709713
/// Installs the new colors as the default colors and recomputes the
710714
/// current and ansi palette. This will not change the UI layer, for that it is better
711715
/// to call the `installColors` method on `TerminalView`, which will
@@ -1759,7 +1763,8 @@ open class Terminal {
17591763
}
17601764

17611765
var hyperLinkTracking: (start: Position, payload: String)? = nil
1762-
1766+
private var payloadCodes = Set<UInt16>()
1767+
17631768
func oscHyperlink (_ data: ArraySlice<UInt8>)
17641769
{
17651770
let buffer = self.buffer
@@ -1768,6 +1773,7 @@ open class Terminal {
17681773
if let hlt = hyperLinkTracking {
17691774
let str = hlt.payload
17701775
if let urlToken = TinyAtom.lookup (value: str) {
1776+
payloadCodes.insert(urlToken.code)
17711777
//print ("Setting the text from \(hlt.start) to \(buffer.x) on line \(buffer.y+buffer.yBase) to \(str)")
17721778

17731779
// Between the time the flag was set, and now `y` might have changed negatively,
@@ -5120,8 +5126,7 @@ open class Terminal {
51205126
* available by scrolling.
51215127
*/
51225128
public func garbageCollectPayload() {
5123-
// stop right away if there is nothing to collect
5124-
if TinyAtom.lastCollected == TinyAtom.lastUsed {
5129+
if payloadCodes.isEmpty {
51255130
return
51265131
}
51275132

@@ -5141,16 +5146,10 @@ open class Terminal {
51415146
}
51425147
}
51435148

5144-
// since we create atoms in order we expect them to run out of use
5145-
// in order as well and stop with first atom that is still in use
5146-
for code in UInt16(TinyAtom.lastCollected + 1)...UInt16(TinyAtom.lastUsed) {
5147-
if used.contains(code) {
5148-
// code still in use
5149-
break
5150-
}
5151-
5152-
TinyAtom.lastCollected = Int(code)
5153-
TinyAtom.release(code: code)
5149+
let released = payloadCodes.subtracting(used)
5150+
if !released.isEmpty {
5151+
TinyAtom.release(codes: released)
5152+
payloadCodes.subtract(released)
51545153
}
51555154
}
51565155

Tests/SwiftTermTests/LinkLookupTests.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,48 @@ final class LinkLookupTests: TerminalDelegate {
4242
#expect(link == "https://example.com")
4343
}
4444

45+
@Test func testGarbageCollectionDoesNotReleaseAnotherTerminalsPayload() {
46+
let first = Terminal(delegate: self, options: TerminalOptions(cols: 20, rows: 1))
47+
let second = Terminal(delegate: self, options: TerminalOptions(cols: 20, rows: 1))
48+
49+
first.feed(text: "\u{1b}]8;;https://first.example\u{07}first\u{1b}]8;;\u{07}")
50+
second.feed(text: "\u{1b}]8;;https://second.example\u{07}second\u{1b}]8;;\u{07}")
51+
52+
let firstAtom = first.displayBuffer.lines[0][0].payload
53+
let secondAtom = second.displayBuffer.lines[0][0].payload
54+
#expect(firstAtom.target != nil)
55+
#expect(secondAtom.target != nil)
56+
57+
first.feed(text: "\u{1b}[2J")
58+
first.garbageCollectPayload()
59+
60+
#expect(firstAtom.target == nil)
61+
#expect(secondAtom.target != nil)
62+
}
63+
64+
@Test func testTinyAtomConcurrentLookupAndRelease() async {
65+
let allValuesMatched = await withTaskGroup(of: Bool.self, returning: Bool.self) { group in
66+
for value in 0..<1_000 {
67+
group.addTask {
68+
guard let atom = TinyAtom.lookup(value: value) else {
69+
return false
70+
}
71+
let matched = atom.target as? Int == value
72+
TinyAtom.release(code: atom.code)
73+
return matched
74+
}
75+
}
76+
77+
var result = true
78+
for await matched in group {
79+
result = result && matched
80+
}
81+
return result
82+
}
83+
84+
#expect(allValuesMatched)
85+
}
86+
4587
@Test func testImplicitUrlLookup() {
4688
let terminal = Terminal(delegate: self, options: TerminalOptions(cols: 40, rows: 1))
4789
terminal.feed(text: "https://example.com tail")

0 commit comments

Comments
 (0)