Skip to content

Commit

Permalink
work around api deprecations in deno 1.40.x (#3609) (#3611)
Browse files Browse the repository at this point in the history
  • Loading branch information
evanw authored Jan 27, 2024
1 parent 22a9cf5 commit 931f87d
Show file tree
Hide file tree
Showing 9 changed files with 198 additions and 62 deletions.
42 changes: 36 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,10 @@ jobs:
with:
node-version: 16

# The version of Deno is pinned because version 1.25.1 was causing test
# flakes due to random segfaults.
- name: Setup Deno 1.24.0
- name: Setup Deno 1.40.0
uses: denoland/setup-deno@main
with:
deno-version: v1.24.0
deno-version: v1.40.0

- name: Check out code into the Go module directory
uses: actions/checkout@v3
Expand Down Expand Up @@ -199,8 +197,8 @@ jobs:
make test-yarnpnp
esbuild-old-versions:
name: esbuild CI (old versions)
esbuild-old-go-version:
name: esbuild CI (old Go version)
runs-on: ubuntu-latest

steps:
Expand All @@ -221,3 +219,35 @@ jobs:

- name: make test-old-ts
run: make test-old-ts

esbuild-old-deno-version:
name: esbuild CI (old Deno version)
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- name: Set up Go 1.x
uses: actions/setup-go@v3
with:
go-version: 1.20.12
id: go

# Make sure esbuild works with old versions of Deno. Note: It's important
# to test a version before 1.31.0, which introduced the "Deno.Command" API.
- name: Setup Deno 1.24.0
uses: denoland/setup-deno@main
with:
deno-version: v1.24.0

- name: Check out code into the Go module directory
uses: actions/checkout@v3

- name: Deno Tests (non-Windows)
if: matrix.os != 'windows-latest'
run: make test-deno

- name: Deno Tests (Windows)
if: matrix.os == 'windows-latest'
run: make test-deno-windows
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## Unreleased

* Work around API deprecations in Deno 1.40.x ([#3609](https://github.com/evanw/esbuild/issues/3609), [#3611](https://github.com/evanw/esbuild/pull/3611))

Deno 1.40.0 introduced run-time warnings about certain APIs that esbuild uses. With this release, esbuild will work around these run-time warnings by using newer APIs if they are present and falling back to the original APIs otherwise. This should avoid the warnings without breaking compatibility with older versions of Deno.

Unfortunately, doing this introduces a breaking change. The newer child process APIs lack a way to synchronously terminate esbuild's child process, so calling `esbuild.stop()` from within a Deno test is no longer sufficient to prevent Deno from failing a test that uses esbuild's API (Deno fails tests that create a child process without killing it before the test ends). To work around this, esbuild's `stop()` function has been changed to return a promise, and you now have to change `esbuild.stop()` to `await esbuild.stop()` in all of your Deno tests.

* Reorder implicit file extensions within `node_modules` ([#3341](https://github.com/evanw/esbuild/issues/3341), [#3608](https://github.com/evanw/esbuild/issues/3608))

In [version 0.18.0](https://github.com/evanw/esbuild/releases/v0.18.0), esbuild changed the behavior of implicit file extensions within `node_modules` directories (i.e. in published packages) to prefer `.js` over `.ts` even when the `--resolve-extensions=` order prefers `.ts` over `.js` (which it does by default). However, doing that also accidentally made esbuild prefer `.css` over `.ts`, which caused problems for people that published packages containing both TypeScript and CSS in files with the same name.
Expand Down
175 changes: 134 additions & 41 deletions lib/deno/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ export const analyzeMetafileSync: typeof types.analyzeMetafileSync = () => {
throw new Error(`The "analyzeMetafileSync" API does not work in Deno`)
}

export const stop = () => {
if (stopService) stopService()
export const stop = async () => {
if (stopService) await stopService()
}

let initializeWasCalled = false
Expand Down Expand Up @@ -178,63 +178,149 @@ interface Service {

let defaultWD = Deno.cwd()
let longLivedService: Promise<Service> | undefined
let stopService: (() => void) | undefined
let stopService: (() => Promise<void>) | undefined

// Declare a common subprocess API for the two implementations below
type SpawnFn = (cmd: string, options: {
args: string[]
stdin: 'piped' | 'inherit'
stdout: 'piped' | 'inherit'
stderr: 'inherit'
}) => {
write(bytes: Uint8Array): void
read(): Promise<Uint8Array | null>
close(): Promise<void> | void
status(): Promise<{ code: number }>
unref(): void
ref(): void
}

// Deno ≥1.40
const spawnNew: SpawnFn = (cmd, { args, stdin, stdout, stderr }) => {
const child = new Deno.Command(cmd, {
args,
cwd: defaultWD,
stdin,
stdout,
stderr,
}).spawn()
const writer = child.stdin.getWriter()
const reader = child.stdout.getReader()
return {
write: bytes => writer.write(bytes),
read: () => reader.read().then(x => x.value || null),
close: async () => {
// We can't call "kill()" because it doesn't seem to work. Tests will
// still fail with "A child process was opened during the test, but not
// closed during the test" even though we kill the child process.
//
// And we can't call both "writer.close()" and "kill()" because then
// there's a race as the child process exits when stdin is closed, and
// "kill()" fails when the child process has already been killed.
//
// So instead we just call "writer.close()" and then hope that this
// causes the child process to exit. It won't work if the stdin consumer
// thread in the child process is hung or busy, but that may be the best
// we can do.
//
// See this for more info: https://github.com/evanw/esbuild/pull/3611
await writer.close()
await reader.cancel()

// Wait for the process to exit. The new "kill()" API doesn't flag the
// process as having exited because processes can technically ignore the
// kill signal. Without this, Deno will fail tests that use esbuild with
// an error because the test spawned a process but didn't wait for it.
await child.status
},
status: () => child.status,
unref: () => child.unref(),
ref: () => child.ref(),
}
}

let ensureServiceIsRunning = (): Promise<Service> => {
// Deno <1.40
const spawnOld: SpawnFn = (cmd, { args, stdin, stdout, stderr }) => {
const child = Deno.run({
cmd: [cmd].concat(args),
cwd: defaultWD,
stdin,
stdout,
stderr,
})
const stdoutBuffer = new Uint8Array(4 * 1024 * 1024)
let writeQueue: Uint8Array[] = []
let isQueueLocked = false

// We need to keep calling "write()" until it actually writes the data
const startWriteFromQueueWorker = () => {
if (isQueueLocked || writeQueue.length === 0) return
isQueueLocked = true
child.stdin!.write(writeQueue[0]).then(bytesWritten => {
isQueueLocked = false
if (bytesWritten === writeQueue[0].length) writeQueue.shift()
else writeQueue[0] = writeQueue[0].subarray(bytesWritten)
startWriteFromQueueWorker()
})
}

return {
write: bytes => {
writeQueue.push(bytes)
startWriteFromQueueWorker()
},
read: () => child.stdout!.read(stdoutBuffer).then(n => n === null ? null : stdoutBuffer.subarray(0, n)),
close: () => {
child.stdin!.close()
child.stdout!.close()
child.close()
},
status: () => child.status(),
unref: () => { },
ref: () => { },
}
}

// This is a shim for "Deno.run" for newer versions of Deno
const spawn: SpawnFn = Deno.Command ? spawnNew : spawnOld

const ensureServiceIsRunning = (): Promise<Service> => {
if (!longLivedService) {
longLivedService = (async (): Promise<Service> => {
const binPath = await install()
const isTTY = Deno.isatty(Deno.stderr.rid)
const isTTY = Deno.stderr.isTerminal
? Deno.stderr.isTerminal() // Deno ≥1.40
: Deno.isatty(Deno.stderr.rid) // Deno <1.40

const child = Deno.run({
cmd: [binPath, `--service=${version}`],
cwd: defaultWD,
const child = spawn(binPath, {
args: [`--service=${version}`],
stdin: 'piped',
stdout: 'piped',
stderr: 'inherit',
})

stopService = () => {
stopService = async () => {
// Close all resources related to the subprocess.
child.stdin.close()
child.stdout.close()
child.close()
await child.close()
initializeWasCalled = false
longLivedService = undefined
stopService = undefined
}

let writeQueue: Uint8Array[] = []
let isQueueLocked = false

// We need to keep calling "write()" until it actually writes the data
const startWriteFromQueueWorker = () => {
if (isQueueLocked || writeQueue.length === 0) return
isQueueLocked = true
child.stdin.write(writeQueue[0]).then(bytesWritten => {
isQueueLocked = false
if (bytesWritten === writeQueue[0].length) writeQueue.shift()
else writeQueue[0] = writeQueue[0].subarray(bytesWritten)
startWriteFromQueueWorker()
})
}

const { readFromStdout, afterClose, service } = common.createChannel({
writeToStdin(bytes) {
writeQueue.push(bytes)
startWriteFromQueueWorker()
child.write(bytes)
},
isSync: false,
hasFS: true,
esbuild: ourselves,
})

const stdoutBuffer = new Uint8Array(4 * 1024 * 1024)
const readMoreStdout = () => child.stdout.read(stdoutBuffer).then(n => {
if (n === null) {
const readMoreStdout = () => child.read().then(buffer => {
if (buffer === null) {
afterClose(null)
} else {
readFromStdout(stdoutBuffer.subarray(0, n))
readFromStdout(buffer)
readMoreStdout()
}
}).catch(e => {
Expand All @@ -247,12 +333,20 @@ let ensureServiceIsRunning = (): Promise<Service> => {
})
readMoreStdout()

let refCount = 0
child.unref() // Allow Deno to exit when esbuild is running

const refs: common.Refs = {
ref() { if (++refCount === 1) child.ref(); },
unref() { if (--refCount === 0) child.unref(); },
}

return {
build: (options: types.BuildOptions) =>
new Promise<types.BuildResult>((resolve, reject) => {
service.buildOrContext({
callName: 'build',
refs: null,
refs,
options,
isTTY,
defaultWD,
Expand All @@ -264,7 +358,7 @@ let ensureServiceIsRunning = (): Promise<Service> => {
new Promise<types.BuildContext>((resolve, reject) =>
service.buildOrContext({
callName: 'context',
refs: null,
refs,
options,
isTTY,
defaultWD,
Expand All @@ -275,7 +369,7 @@ let ensureServiceIsRunning = (): Promise<Service> => {
new Promise<types.TransformResult>((resolve, reject) =>
service.transform({
callName: 'transform',
refs: null,
refs,
input,
options: options || {},
isTTY,
Expand Down Expand Up @@ -308,7 +402,7 @@ let ensureServiceIsRunning = (): Promise<Service> => {
new Promise((resolve, reject) =>
service.formatMessages({
callName: 'formatMessages',
refs: null,
refs,
messages,
options,
callback: (err, res) => err ? reject(err) : resolve(res!),
Expand All @@ -318,7 +412,7 @@ let ensureServiceIsRunning = (): Promise<Service> => {
new Promise((resolve, reject) =>
service.analyzeMetafile({
callName: 'analyzeMetafile',
refs: null,
refs,
metafile: typeof metafile === 'string' ? metafile : JSON.stringify(metafile),
options,
callback: (err, res) => err ? reject(err) : resolve(res!),
Expand All @@ -331,9 +425,8 @@ let ensureServiceIsRunning = (): Promise<Service> => {

// If we're called as the main script, forward the CLI to the underlying executable
if (import.meta.main) {
Deno.run({
cmd: [await install()].concat(Deno.args),
cwd: defaultWD,
spawn(await install(), {
args: Deno.args,
stdin: 'inherit',
stdout: 'inherit',
stderr: 'inherit',
Expand Down
1 change: 1 addition & 0 deletions lib/deno/wasm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const analyzeMetafileSync: typeof types.analyzeMetafileSync = () => {

export const stop = () => {
if (stopService) stopService()
return Promise.resolve()
}

interface Service {
Expand Down
1 change: 1 addition & 0 deletions lib/npm/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export const analyzeMetafileSync: typeof types.analyzeMetafileSync = () => {

export const stop = () => {
if (stopService) stopService()
return Promise.resolve()
}

interface Service {
Expand Down
1 change: 1 addition & 0 deletions lib/npm/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ export let analyzeMetafileSync: typeof types.analyzeMetafileSync = (metafile, op
export const stop = () => {
if (stopService) stopService()
if (workerThreadService) workerThreadService.stop()
return Promise.resolve()
}

let initializeWasCalled = false
Expand Down
21 changes: 12 additions & 9 deletions lib/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -663,14 +663,17 @@ export interface InitializeOptions {
export let version: string

// Call this function to terminate esbuild's child process. The child process
// is not terminated and re-created for each API call because it's more
// efficient to keep it around when there are multiple API calls.
// is not terminated and re-created after each API call because it's more
// efficient to keep it around when there are multiple API calls. This child
// process normally exits automatically when the parent process exits, so you
// usually don't need to call this function.
//
// In node this happens automatically before the parent node process exits. So
// you only need to call this if you know you will not make any more esbuild
// API calls and you want to clean up resources.
// One reason you might want to call this is if you know you will not make any
// more esbuild API calls and you want to clean up resources (since the esbuild
// child process takes up some memory even when idle).
//
// Unlike node, Deno lacks the necessary APIs to clean up child processes
// automatically. You must manually call stop() in Deno when you're done
// using esbuild or Deno will continue running forever.
export declare function stop(): void;
// Another reason you might want to call this is if you are using esbuild from
// within a Deno test. Deno fails tests that create a child process without
// killing it before the test ends, so you have to call this function (and
// await the returned promise) in every Deno test that uses esbuild.
export declare function stop(): Promise<void>
Loading

0 comments on commit 931f87d

Please sign in to comment.