Skip to content

Commit 3f0655f

Browse files
Offload Oxide scanning to separate process (#1471)
Loading Oxide's .node file into the language server process / VSCode extension host on Windows marks that .node file as in use. When this happens you cannot completely delete node_modules (for example by running `npm ci`). To work around this we'll fork a new process that can load Oxide, run its scan(s), and then exit. During initial project discovery we use a temporarily long lived process that persists for the duration of project discovery — which may include multiple Oxide scans across projects (and even versions). Once the project discovery has completed the process will exit. For any subsequent content scanning (e.g. when CSS changes) we'll spawn a new, temporary process for that individual scan. This will ensure that, once the process has exited, the `.node` file is no longer considered to be in-use and commands like `npm ci` will run properly. ## Commentary ### Why not use `require.cache`? Unfortunately, deleting entries from `require.cache` does not unload the Oxide binary from the process address space. ### Why not worker threads So, this might work but also might not. You'd still be loading it into the processes address space so there's a chance that as long as the process is open — whether the thread has exited or not — the `.node` file would still be marked as in use. Additionally, we have some flags set when building Oxide that basically prevent it from unloading in worker threads due to some bugs in the Rust standard library. This applies to Linux only iirc so it shouldn't actually be a problem there but I'd rather keep the mechanism working consistently across operating systems. ### Communication between processes The main process and helper communicate using a JSON-RPC protocol — similar to the one used by language servers/clients — but without any initialization setup. This is an internal tool and the message format is not considered stable and may change in any future version. Communication happens over an IPC channel provided by `child_process.fork(…)`. As far as I am aware, this uses private file descriptors shared between processes. No other process should be capable of "tricking" the helper into loading other `.node` files into its address space. Only the ones we discover during NPM package resolution should ever be loaded. Even though the temporary helper isn't active for very long this was still a concern I had while developing this. ## Test Plan There are automated tests that verify existing functionality still works but testing this specific scenario on Windows in an automated fashion with the current test setup would be a bit annoying so I did some additional manual testing: - Set up a small Tailwind CSS v4 project on Windows - Opened VS Code with the current version of the extension - Ran `npm ci` in the terminal and watched it fail b/c of the Oxide `.node` file - Loaded the new extension through the extension development host - Repeated the steps - Ran `npm ci` multiple times to make sure it worked
1 parent a15f9c2 commit 3f0655f

File tree

9 files changed

+183
-12
lines changed

9 files changed

+183
-12
lines changed

packages/tailwindcss-language-server/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@
1313
},
1414
"homepage": "https://github.com/tailwindlabs/tailwindcss-intellisense/tree/HEAD/packages/tailwindcss-language-server#readme",
1515
"scripts": {
16-
"build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:css",
16+
"build": "pnpm run clean && pnpm run _esbuild && pnpm run _esbuild:oxide && pnpm run _esbuild:css",
1717
"_esbuild": "node ../../esbuild.mjs src/server.ts --outfile=bin/tailwindcss-language-server --minify",
18+
"_esbuild:oxide": "node ../../esbuild.mjs src/oxide-helper.ts --outfile=bin/oxide-helper.js --minify",
1819
"_esbuild:css": "node ../../esbuild.mjs src/language/css.ts --outfile=bin/css-language-server --minify",
1920
"clean": "rimraf bin",
2021
"prepublishOnly": "pnpm run build",
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
3+
import * as rpc from 'vscode-jsonrpc/node'
4+
import { scan, type ScanOptions, type ScanResult } from './oxide'
5+
6+
let connection = rpc.createMessageConnection(
7+
new rpc.IPCMessageReader(process),
8+
new rpc.IPCMessageWriter(process),
9+
)
10+
11+
let scanRequest = new rpc.RequestType<ScanOptions, ScanResult, void>('scan')
12+
connection.onRequest<ScanOptions, ScanResult, void>(scanRequest, (options) => scan(options))
13+
14+
connection.listen()
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import * as rpc from 'vscode-jsonrpc/node'
2+
import * as proc from 'node:child_process'
3+
import * as path from 'node:path'
4+
import * as fs from 'node:fs/promises'
5+
import { type ScanOptions, type ScanResult } from './oxide'
6+
7+
/**
8+
* This helper starts a session in which we can use Oxide in *another process*
9+
* to communicate content scanning results.
10+
*
11+
* Thie exists for two reasons:
12+
* - The Oxide API has changed over time so this function presents a unified
13+
* interface that works with all versions of the Oxide API. The results may
14+
* vary but the structure of the results will always be identical.
15+
*
16+
* - Requiring a native node module on Windows permanently keeps an open handle
17+
* to the binary for the duration of the process. This prevents unlinking the
18+
* file like happens when running `npm ci`. Running an ephemeral process lets
19+
* us sidestep the problem as the process will only be running as needed.
20+
*/
21+
export class OxideSession {
22+
helper: proc.ChildProcess | null = null
23+
connection: rpc.MessageConnection | null = null
24+
25+
public async scan(options: ScanOptions): Promise<ScanResult> {
26+
await this.startIfNeeded()
27+
28+
return await this.connection.sendRequest('scan', options)
29+
}
30+
31+
async startIfNeeded(): Promise<void> {
32+
if (this.connection) return
33+
34+
// TODO: Can we find a way to not require a build first?
35+
// let module = path.resolve(path.dirname(__filename), './oxide-helper.ts')
36+
37+
let modulePaths = [
38+
// Separate Language Server package
39+
'../bin/oxide-helper.js',
40+
41+
// Bundled with the VSCode extension
42+
'../dist/oxide-helper.js',
43+
]
44+
45+
let module: string | null = null
46+
47+
for (let relativePath of modulePaths) {
48+
let filepath = path.resolve(path.dirname(__filename), relativePath)
49+
50+
if (
51+
await fs.access(filepath).then(
52+
() => true,
53+
() => false,
54+
)
55+
) {
56+
module = filepath
57+
break
58+
}
59+
}
60+
61+
if (!module) throw new Error('unable to load')
62+
63+
let helper = proc.fork(module)
64+
let connection = rpc.createMessageConnection(
65+
new rpc.IPCMessageReader(helper),
66+
new rpc.IPCMessageWriter(helper),
67+
)
68+
69+
helper.on('disconnect', () => {
70+
connection.dispose()
71+
this.connection = null
72+
this.helper = null
73+
})
74+
75+
helper.on('exit', () => {
76+
connection.dispose()
77+
this.connection = null
78+
this.helper = null
79+
})
80+
81+
connection.listen()
82+
83+
this.helper = helper
84+
this.connection = connection
85+
}
86+
87+
async stop() {
88+
if (!this.helper) return
89+
90+
this.helper.disconnect()
91+
this.helper.kill()
92+
}
93+
}

packages/tailwindcss-language-server/src/oxide.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,14 @@ interface SourceEntry {
111111
negated: boolean
112112
}
113113

114-
interface ScanOptions {
114+
export interface ScanOptions {
115115
oxidePath: string
116116
oxideVersion: string
117117
basePath: string
118118
sources: Array<SourceEntry>
119119
}
120120

121-
interface ScanResult {
121+
export interface ScanResult {
122122
files: Array<string>
123123
globs: Array<GlobEntry>
124124
}

packages/tailwindcss-language-server/src/project-locator.ts

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { normalizeDriveLetter, normalizePath, pathToFileURL } from './utils'
1616
import postcss from 'postcss'
1717
import * as oxide from './oxide'
1818
import { analyzeStylesheet, TailwindStylesheet } from './version-guesser'
19+
import { OxideSession } from './oxide-session'
1920

2021
export interface ProjectConfig {
2122
/** The folder that contains the project */
@@ -60,7 +61,10 @@ export class ProjectLocator {
6061
let configs = await this.findConfigs()
6162

6263
// Create a project for each of the config files
63-
let results = await Promise.allSettled(configs.map((config) => this.createProject(config)))
64+
let session = new OxideSession()
65+
let results = await Promise.allSettled(
66+
configs.map((config) => this.createProject(config, session)),
67+
)
6468
let projects: ProjectConfig[] = []
6569

6670
for (let result of results) {
@@ -98,6 +102,8 @@ export class ProjectLocator {
98102
}
99103
}
100104

105+
await session.stop()
106+
101107
return projects
102108
}
103109

@@ -148,7 +154,10 @@ export class ProjectLocator {
148154
}
149155
}
150156

151-
private async createProject(config: ConfigEntry): Promise<ProjectConfig | null> {
157+
private async createProject(
158+
config: ConfigEntry,
159+
session: OxideSession,
160+
): Promise<ProjectConfig | null> {
152161
let tailwind = await this.detectTailwindVersion(config)
153162

154163
let possibleVersions = config.entries.flatMap((entry) => entry.meta?.versions ?? [])
@@ -218,7 +227,12 @@ export class ProjectLocator {
218227
// Look for the package root for the config
219228
config.packageRoot = await getPackageRoot(path.dirname(config.path), this.base)
220229

221-
let selectors = await calculateDocumentSelectors(config, tailwind.features, this.resolver)
230+
let selectors = await calculateDocumentSelectors(
231+
config,
232+
tailwind.features,
233+
this.resolver,
234+
session,
235+
)
222236

223237
return {
224238
config,
@@ -520,10 +534,11 @@ function contentSelectorsFromConfig(
520534
entry: ConfigEntry,
521535
features: Feature[],
522536
resolver: Resolver,
537+
session: OxideSession,
523538
actualConfig?: any,
524539
): AsyncIterable<DocumentSelector> {
525540
if (entry.type === 'css') {
526-
return contentSelectorsFromCssConfig(entry, resolver)
541+
return contentSelectorsFromCssConfig(entry, resolver, session)
527542
}
528543

529544
if (entry.type === 'js') {
@@ -582,6 +597,7 @@ async function* contentSelectorsFromJsConfig(
582597
async function* contentSelectorsFromCssConfig(
583598
entry: ConfigEntry,
584599
resolver: Resolver,
600+
session: OxideSession,
585601
): AsyncIterable<DocumentSelector> {
586602
let auto = false
587603
for (let item of entry.content) {
@@ -606,6 +622,7 @@ async function* contentSelectorsFromCssConfig(
606622
entry.path,
607623
sources,
608624
resolver,
625+
session,
609626
)) {
610627
yield {
611628
pattern,
@@ -621,14 +638,15 @@ async function* detectContentFiles(
621638
inputFile: string,
622639
sources: SourcePattern[],
623640
resolver: Resolver,
641+
session: OxideSession,
624642
): AsyncIterable<string> {
625643
try {
626644
let oxidePath = await resolver.resolveJsId('@tailwindcss/oxide', base)
627645
oxidePath = pathToFileURL(oxidePath).href
628646
let oxidePackageJsonPath = await resolver.resolveJsId('@tailwindcss/oxide/package.json', base)
629647
let oxidePackageJson = JSON.parse(await fs.readFile(oxidePackageJsonPath, 'utf8'))
630648

631-
let result = await oxide.scan({
649+
let result = await session.scan({
632650
oxidePath,
633651
oxideVersion: oxidePackageJson.version,
634652
basePath: base,
@@ -654,11 +672,23 @@ async function* detectContentFiles(
654672
base = normalizeDriveLetter(base)
655673
yield `${base}/${pattern}`
656674
}
657-
} catch {
658-
//
675+
} catch (err) {
676+
if (isResolutionError(err)) return
677+
678+
console.error(err)
659679
}
660680
}
661681

682+
function isResolutionError(err: unknown): boolean {
683+
return (
684+
err &&
685+
typeof err === 'object' &&
686+
'message' in err &&
687+
typeof err.message === 'string' &&
688+
err.message.includes("Can't resolve")
689+
)
690+
}
691+
662692
type ContentItem =
663693
| { kind: 'file'; file: string }
664694
| { kind: 'raw'; content: string }
@@ -812,8 +842,15 @@ export async function calculateDocumentSelectors(
812842
config: ConfigEntry,
813843
features: Feature[],
814844
resolver: Resolver,
845+
session?: OxideSession,
815846
actualConfig?: any,
816847
) {
848+
let hasTemporarySession = false
849+
if (!session) {
850+
hasTemporarySession = true
851+
session = new OxideSession()
852+
}
853+
817854
let selectors: DocumentSelector[] = []
818855

819856
// selectors:
@@ -834,7 +871,13 @@ export async function calculateDocumentSelectors(
834871
})
835872

836873
// - Content patterns from config
837-
for await (let selector of contentSelectorsFromConfig(config, features, resolver, actualConfig)) {
874+
for await (let selector of contentSelectorsFromConfig(
875+
config,
876+
features,
877+
resolver,
878+
session,
879+
actualConfig,
880+
)) {
838881
selectors.push(selector)
839882
}
840883

@@ -876,5 +919,9 @@ export async function calculateDocumentSelectors(
876919
return 0
877920
})
878921

922+
if (hasTemporarySession) {
923+
await session.stop()
924+
}
925+
879926
return selectors
880927
}

packages/tailwindcss-language-server/src/projects.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,7 @@ export async function createProjectService(
964964
projectConfig.config,
965965
state.features,
966966
resolver,
967+
undefined,
967968
originalConfig,
968969
)
969970
}

packages/tailwindcss-language-server/tests/common.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,18 @@ export async function init(
9191
projectDetails = project
9292
})
9393

94+
// TODO: This shouldn't be needed
95+
// The server should either delay requests *or*
96+
// openDocument shouldn't return until the project its a part of has been
97+
// built otherwise all requests will return nothing and it's not something
98+
// we can await directly right now
99+
//
100+
// Like maybe documentReady should be delayed by project build state?
101+
// because otherwise the document isn't really ready anyway
102+
let projectBuilt = new Promise<void>((resolve) => {
103+
client.conn.onNotification('@/tailwindCSS/projectReloaded', () => resolve())
104+
})
105+
94106
return {
95107
client,
96108
fixtureUri(fixture: string) {
@@ -130,6 +142,8 @@ export async function init(
130142
settings,
131143
})
132144

145+
await projectBuilt
146+
133147
return {
134148
get uri() {
135149
return doc.uri.toString()

packages/vscode-tailwindcss/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@
373373
}
374374
},
375375
"scripts": {
376-
"_esbuild": "node ../../esbuild.mjs src/extension.ts src/server.ts src/cssServer.ts --outdir=dist",
376+
"_esbuild": "node ../../esbuild.mjs src/extension.ts src/server.ts src/cssServer.ts src/oxide-helper.ts --outdir=dist",
377377
"dev": "concurrently --raw --kill-others \"pnpm run watch\" \"pnpm run check --watch\"",
378378
"watch": "pnpm run clean && pnpm run _esbuild --watch",
379379
"build": "pnpm run check && pnpm run clean && pnpm run _esbuild --minify && move-file dist/server.js dist/tailwindServer.js && move-file dist/cssServer.js dist/tailwindModeServer.js",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import '@tailwindcss/language-server/src/oxide-helper'

0 commit comments

Comments
 (0)